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

# How to schedule subscription downgrades with Upstash QStash

> Defer a subscription downgrade to the end of the billing period with the Macropay API and Upstash QStash — no cron server required.

## Why defer a downgrade?

A customer clicks "switch to a cheaper plan." If you apply that change instantly, you either claw back what they already paid or hand them service they didn't pay for. Neither feels fair.

The cleaner model: let the customer keep everything they paid for until the current period ends, then flip them to the lower tier at renewal. The hard part is the *timing* — you need something to fire an API call days or weeks from now, exactly when the period closes.

This guide wires up that timer with **Upstash QStash**, a serverless message queue that holds a delayed HTTP request and delivers it on schedule. No always-on worker, no cron box to babysit. Two endpoints and a signed callback do the whole job.

<Info>
  Because Macropay is the **merchant of record**, the plan change you trigger here is also reflected on the customer's invoice and statement — and any sales tax or VAT on the new price is recalculated and remitted by Macropay automatically. You schedule the downgrade; the tax math takes care of itself.
</Info>

## How the flow works

```
Customer requests downgrade
        │
        ▼
 /api/schedule-downgrade ──► QStash (holds delayed message until period end)
                                      │
                          period ends │
                                      ▼
                          /api/execute-downgrade ──► macropay.subscriptions.update()
```

1. Your `schedule-downgrade` route reads the subscription's `currentPeriodEnd` and asks QStash to call you back at that moment.
2. When the time arrives, QStash POSTs to `execute-downgrade` with a signed request.
3. That route re-checks the subscription is still active, then applies the new product.

## What you'll need

* A [Macropay access token](/integrate/authentication)
* An [Upstash QStash](https://upstash.com/qstash) account and token
* A publicly reachable HTTP endpoint (QStash must be able to reach it to deliver the callback)
* A backend runtime — the examples use [Node.js](https://nodejs.org/en/blog/announcements/v20-release-announce) with Next.js, but any language works

## Step 1: Configure credentials

Keep secrets in environment variables, never in source.

```bash Terminal theme={null}
# Macropay
MACROPAY_MODE="sandbox"                 # switch to "production" when you go live
MACROPAY_ACCESS_TOKEN="macropay_pat_..."

# Upstash QStash
QSTASH_URL="https://qstash.upstash.io"
QSTASH_TOKEN="ey...="
QSTASH_CURRENT_SIGNING_KEY="sig_..."    # used to verify inbound QStash requests
QSTASH_NEXT_SIGNING_KEY="sig_..."       # used during key rotation

# Where QStash will call you back
APP_URL="https://your-app.example.com"
```

<Info>
  Grab your token and signing keys from the **QStash** section of the [Upstash Console](https://console.upstash.com/qstash).
</Info>

## Step 2: Schedule the callback

This route fetches the subscription, computes the delay until the period closes, and hands QStash a message to deliver to your execution endpoint.

```tsx title="app/api/schedule-downgrade/route.ts" theme={null}
import { Macropay } from "@macropayments/sdk";
import { Client } from "@upstash/qstash";
import { NextRequest, NextResponse } from "next/server";

const macropay = new Macropay({
  server: process.env.MACROPAY_MODE,
  accessToken: process.env.MACROPAY_ACCESS_TOKEN,
});

const qstash = new Client({
  token: process.env.QSTASH_TOKEN,
});

export async function POST(req: NextRequest) {
  try {
    const { subscriptionId, newProductId } = await req.json();

    // 1. Read the subscription so we know when the current period ends.
    const subscription = await macropay.subscriptions.get({
      id: subscriptionId,
    });

    if (!subscription.currentPeriodEnd) {
      return NextResponse.json(
        { error: "Subscription has no current period end date" },
        { status: 400 }
      );
    }

    // 2. The downgrade should land exactly when the paid period closes.
    const executeAt = new Date(subscription.currentPeriodEnd);
    const delaySeconds = Math.floor(
      (executeAt.getTime() - Date.now()) / 1000
    );

    // 3. Hand the delayed callback to QStash.
    const result = await qstash.publishJSON({
      retries: 1,
      url: `${process.env.APP_URL}/api/execute-downgrade`,
      delay: delaySeconds,
      body: {
        subscriptionId,
        newProductId,
        customerId: subscription.customerId,
      },
    });

    // 4. Persist result.messageId so you can cancel or audit later.
    console.log(
      `Downgrade for ${subscriptionId} queued; fires ${executeAt.toISOString()}`
    );

    return NextResponse.json({
      success: true,
      messageId: result.messageId,
      scheduledFor: executeAt.toISOString(),
    });
  } catch (error) {
    console.error("Failed to schedule downgrade:", error);
    return NextResponse.json(
      { error: "Failed to schedule downgrade" },
      { status: 500 }
    );
  }
}
```

<Tip>
  Save the returned `messageId` next to the subscription in your database. It's your handle for cancelling a scheduled downgrade if the customer changes their mind, and a useful audit trail when support asks "why did this plan switch?"
</Tip>

## Step 3: Execute the downgrade on the callback

QStash calls this route at the scheduled moment. Before mutating anything, it re-validates the subscription — a lot can change in a billing cycle, and a stale message shouldn't clobber a subscription that the customer already cancelled or re-upgraded.

```tsx title="app/api/execute-downgrade/route.ts" theme={null}
import { Macropay } from "@macropayments/sdk";
import { NextRequest, NextResponse } from "next/server";
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
import { SubscriptionProrationBehavior } from "@macropayments/sdk/models/components/subscriptionprorationbehavior.js";

const macropay = new Macropay({
  server: process.env.MACROPAY_MODE,
  accessToken: process.env.MACROPAY_ACCESS_TOKEN,
});

async function handler(req: NextRequest) {
  try {
    const { subscriptionId, newProductId } = await req.json();

    // Re-fetch — the world may have moved since we queued this.
    const subscription = await macropay.subscriptions.get({
      id: subscriptionId,
    });

    // Skip if the subscription is no longer billable.
    if (subscription.status !== "active" && subscription.status !== "trialing") {
      console.log(`Subscription ${subscriptionId} is ${subscription.status}; skipping`);
      return NextResponse.json({
        success: false,
        reason: "Subscription is not active",
      });
    }

    // Skip if the customer already landed on the target plan.
    if (subscription.productId === newProductId) {
      return NextResponse.json({
        success: true,
        reason: "Already on target product",
      });
    }

    // Apply the new product and invoice the difference at the boundary.
    const updatedSubscription = await macropay.subscriptions.update({
      id: subscriptionId,
      subscriptionUpdate: {
        productId: newProductId,
        prorationBehavior: SubscriptionProrationBehavior.Invoice,
      },
    });

    console.log(
      `Downgraded ${subscriptionId} → ${updatedSubscription.product.name}`
    );

    return NextResponse.json({
      success: true,
      subscription: updatedSubscription,
    });
  } catch (error) {
    console.error("Failed to execute downgrade:", error);

    // 200 = "don't retry": the subscription is gone, retrying won't help.
    if (error instanceof Error && error.message.includes("not found")) {
      return NextResponse.json(
        { success: false, error: "Subscription not found" },
        { status: 200 }
      );
    }

    // 500 = transient failure: let QStash retry per the `retries` setting.
    return NextResponse.json(
      { error: "Failed to execute downgrade" },
      { status: 500 }
    );
  }
}

// Only accept requests QStash actually signed.
export const POST = verifySignatureAppRouter(handler);
```

<Warning>
  Your execution endpoint is public, so treat any unsigned request as hostile. `verifySignatureAppRouter` checks the QStash signature against your signing keys and rejects anything else — without it, anyone who guesses the URL could downgrade a customer at will.
</Warning>

### Status codes are your retry contract

The status code you return tells QStash what to do next. Get this wrong and you'll either drop downgrades or replay them forever.

| Return code | Meaning                                                       | QStash behavior                   |
| ----------- | ------------------------------------------------------------- | --------------------------------- |
| `200`       | Done, or permanently un-retryable (e.g. subscription deleted) | Marks delivered, stops            |
| `500`       | Transient failure (network blip, timeout)                     | Retries up to the `retries` count |

## Step 4: Test the full loop

<Steps>
  <Step title="Run against sandbox">
    Point the SDK at the [sandbox environment](/integrate/sandbox) so test downgrades never touch live customers or real money.

    ```tsx theme={null}
    const macropay = new Macropay({
      server: process.env.MACROPAY_MODE, // "sandbox"
      accessToken: process.env.MACROPAY_ACCESS_TOKEN,
    });
    ```
  </Step>

  <Step title="Queue a downgrade a few minutes out">
    You don't want to wait a full billing cycle to test. Trigger the scheduler against a test subscription and let it compute a short delay.

    ```tsx theme={null}
    const res = await fetch("/api/schedule-downgrade", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        subscriptionId: "sub_test_xxxxx",
        newProductId: "prod_starter_xxxxx",
      }),
    });

    const { messageId, scheduledFor } = await res.json();
    console.log(`Queued ${messageId}, fires ${scheduledFor}`);
    ```
  </Step>

  <Step title="Watch it in the QStash console">
    Open the [Upstash Console](https://console.upstash.com/qstash) to confirm the message is queued, then watch it transition to delivered when the delay elapses.
  </Step>

  <Step title="Confirm the plan switched">
    After the callback fires, read the subscription back and check the product.

    ```tsx theme={null}
    const subscription = await macropay.subscriptions.get({
      id: "sub_test_xxxxx",
    });

    console.log("Now on:", subscription.product.name);
    ```
  </Step>
</Steps>

## Adapting the pattern

The same schedule-then-execute shape covers more than tier downgrades:

* **End-of-trial conversions** — queue the callback for the trial's end and swap the customer onto the paid product.
* **Scheduled cancellations** — call `subscriptions.update` (or your cancel flow) at period end instead of a product swap.
* **Usage-based plan moves** — pair the boundary with Macropay's [metered billing](/integrate/sandbox) so the customer's meters reset cleanly on the new plan.

In every case the principle holds: honor the period the customer already paid for, then make the change land precisely at the boundary.
