> ## Documentation Index
> Fetch the complete documentation index at: https://rain-sandbox-trial.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner-Managed

> Integrate Partner-Managed travel redemptions, from the redemption flow to the code recipes.

For Partner-Managed programs, Rain orchestrates travel redemptions against your balance source and fires `raindrop_redemption.completed` once a booking is confirmed. What you build depends on where your points balances live: **Onchain**, where you are the source of truth and Rain calls you for balances and burns, or **Offchain**, where Rain keeps the canonical ledger and you mostly fulfill the booking. This page walks through the flow for both modes and the code recipes that go with each. For shared reference such as status values, API endpoints, and billing, see [Travel Redemptions](/docs/rewards/redemptions/travel-redemptions).

<Info>
  Partner-Managed programs skip the Rain-Managed user authorization step. A fresh balance check (webhook for onchain, DB for offchain) is the pre-commit gate before a booking, and there is no `raindrop_redemption.created` webhook for partner-managed travel redemptions.
</Info>

## Launch the travel portal

Both modes open the travel portal the same way Rain-Managed programs do: in a webview, with an encrypted session token (a JWE) that identifies the cardholder. The token recipe is identical across modes—see [Launch the travel portal](/docs/rewards/redemptions/travel-redemptions/rain-managed#launch-the-travel-portal) on the Rain-Managed page.

<Info>
  Rain signs every webhook below with an HMAC SHA256 signature in the `Signature` header, computed over the exact JSON body using your API key as the secret. Verify it before processing (see [How Webhooks Work](/docs/webhooks)). Some tenants also receive a `version` field on redemption webhooks; treat it as optional and ignore any fields you don't recognize.
</Info>

## Partner-Managed Onchain

<Frame caption="Partner Managed (onchain) travel redemption flow">
  <img src="https://mintcdn.com/rain-sandbox-trial/Wq6B8qaLw_507Bys/images/docs/travel-redemption-flow-partner-managed-onchain.png?fit=max&auto=format&n=Wq6B8qaLw_507Bys&q=85&s=21d09ade69608c8364f6611a89f254d8" alt="Partner Managed (onchain) travel redemption flow" width="5600" height="9664" data-path="images/docs/travel-redemption-flow-partner-managed-onchain.png" />
</Frame>

You are the source of truth for points balances, so you implement three handlers: a synchronous **balance check** Rain calls before a booking, a **completion** webhook that tells you to burn the points, and a **refund** webhook that tells you to restore them. All point amounts below are whole points (1 point = \$0.01), not native token units.

### Balance check

Rain sends a synchronous `raindrop_balance.requested` webhook whenever the travel portal needs to display or verify a balance. Respond within **1500ms** with the cardholder's spendable balance—confirmed points minus any you've already reserved for in-flight redemptions, so the portal can't double-spend.

**Request payload:**

```json theme={null}
{
  "id": "webhook-id",
  "resource": "raindrop_balance",
  "action": "requested",
  "version": "1.0.0",
  "body": {
    "id": "1f2e3d4c-5b6a-7980-1234-567890abcdef"
  }
}
```

`body.id` is the cardholder's Rain user ID.

**Your response:**

```json theme={null}
{
  "availableBalance": "2500"
}
```

`availableBalance` must be a non-negative integer string of whole points. If your endpoint times out, returns non-2xx, or returns a malformed response, Rain returns 503 to the travel portal.

### Booking completion

When a booking is confirmed, Rain fires `raindrop_redemption.completed`. Acknowledge it immediately, then burn the points in your system out of band. `raindropAmount` is in whole points—scale it to your token's native units before burning.

```json theme={null}
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "resource": "raindrop_redemption",
  "action": "completed",
  "body": {
    "id": "9a8b7c6d-5e4f-3210-fedc-ba0987654321",
    "userId": "1f2e3d4c-5b6a-7980-1234-567890abcdef",
    "type": "TRAVEL_PORTAL",
    "raindropAmount": "2500",
    "bookingId": "booking_abc123"
  }
}
```

### Refund

When a booking is canceled, Rain fires `raindrop_redemption.refunded` so you re-credit the points. The payload carries no amount, so look up how many points you debited by `body.id` (the redemption ID from the completion webhook).

```json theme={null}
{
  "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "resource": "raindrop_redemption",
  "action": "refunded",
  "body": {
    "id": "9a8b7c6d-5e4f-3210-fedc-ba0987654321",
    "userId": "1f2e3d4c-5b6a-7980-1234-567890abcdef"
  }
}
```

### Webhook listener

A single endpoint handles all three. The balance check responds synchronously; the completion and refund handlers acknowledge first and process out of band.

```ts title="partnerOnchainWebhookListener.ts" theme={null}
import express from "express";
import { createHmac } from "crypto";

const API_KEY = process.env.RAIN_API_KEY!;
const PORT = Number(process.env.PORT ?? 3000);

function verifyWebhookSignature(rawBody: string, signature: string): boolean {
  const expected = createHmac("sha256", API_KEY).update(rawBody).digest("hex");
  return expected === signature;
}

// You own the ledger. Amounts are whole points (1 point = $0.01).
async function getSpendableBalance(userId: string): Promise<bigint> {
  // Return confirmed points minus points already reserved for in-flight
  // redemptions, so the portal can't double-spend.
  return 0n;
}

async function burnPoints(userId: string, redemptionId: string, points: bigint): Promise<void> {
  // Debit `points` from the user and burn the token onchain. Record the debit
  // immediately so getSpendableBalance() excludes it before the burn confirms.
  // Retry on failure — the booking is already confirmed, so the points must be
  // burned to avoid drift.
}

async function restorePoints(userId: string, redemptionId: string): Promise<void> {
  // Re-credit the points you debited for this redemption. The refund payload
  // has no amount, so resolve it by redemptionId.
}

const app = express();
app.use(express.json({ verify: (req: any, _res, buf) => { req.rawBody = buf.toString(); } }));

app.post("/webhooks", async (req: any, res) => {
  const signature = req.headers["signature"] as string;
  if (!signature || !verifyWebhookSignature(req.rawBody, signature)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const { resource, action, body } = req.body;

  // Synchronous balance check — respond within 1500ms.
  if (resource === "raindrop_balance" && action === "requested") {
    const balance = await getSpendableBalance(body.id);
    return res.status(200).json({ availableBalance: balance.toString() });
  }

  // Async events — acknowledge first, then process out of band.
  if (resource === "raindrop_redemption" && action === "completed") {
    res.status(200).json({ ok: true });
    try {
      await burnPoints(body.userId, body.id, BigInt(body.raindropAmount));
    } catch (err) {
      console.error(`Failed to burn points for redemption ${body.id}:`, err);
    }
    return;
  }

  if (resource === "raindrop_redemption" && action === "refunded") {
    res.status(200).json({ ok: true });
    try {
      await restorePoints(body.userId, body.id);
    } catch (err) {
      console.error(`Failed to restore points for redemption ${body.id}:`, err);
    }
    return;
  }

  return res.status(200).json({ ok: true });
});

app.listen(PORT, () => console.log(`Partner webhook listener running on port ${PORT}`));
```

<Warning>
  Delivery is at-least-once, so dedupe on `body.id` (the redemption ID) before burning or restoring. Burns must be retried until they succeed: the booking is already confirmed, so a dropped burn strands the debit.
</Warning>

## Partner-Managed Offchain

<Frame caption="Partner Managed (offchain) travel redemption flow">
  <img src="https://mintcdn.com/rain-sandbox-trial/Wq6B8qaLw_507Bys/images/docs/travel-redemption-flow-partner-managed-offchain.png?fit=max&auto=format&n=Wq6B8qaLw_507Bys&q=85&s=9aee6f92057b00100382ec2e3ebfd329" alt="Partner Managed (offchain) travel redemption flow" width="5600" height="7596" data-path="images/docs/travel-redemption-flow-partner-managed-offchain.png" />
</Frame>

Rain keeps the canonical points ledger in its database, so it handles balances and refunds for you. There is **no** balance webhook (Rain reads its own DB) and **no** refund webhook (Rain restores the DB balance directly). The only travel webhook you receive is `raindrop_redemption.completed`, and it is informational—Rain has already debited the points by the time it fires. Use it to record the booking or trigger fulfillment in your own system.

```json theme={null}
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "resource": "raindrop_redemption",
  "action": "completed",
  "body": {
    "id": "9a8b7c6d-5e4f-3210-fedc-ba0987654321",
    "userId": "1f2e3d4c-5b6a-7980-1234-567890abcdef",
    "type": "TRAVEL_PORTAL",
    "raindropAmount": "2500",
    "bookingId": "booking_abc123"
  }
}
```

```ts title="partnerOffchainWebhookListener.ts" theme={null}
import express from "express";
import { createHmac } from "crypto";

const API_KEY = process.env.RAIN_API_KEY!;
const PORT = Number(process.env.PORT ?? 3000);

function verifyWebhookSignature(rawBody: string, signature: string): boolean {
  const expected = createHmac("sha256", API_KEY).update(rawBody).digest("hex");
  return expected === signature;
}

async function recordCompletedRedemption(redemptionId: string, userId: string): Promise<void> {
  // Rain already debited its ledger. Record the booking or trigger fulfillment.
}

const app = express();
app.use(express.json({ verify: (req: any, _res, buf) => { req.rawBody = buf.toString(); } }));

app.post("/webhooks", async (req: any, res) => {
  const signature = req.headers["signature"] as string;
  if (!signature || !verifyWebhookSignature(req.rawBody, signature)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  // Acknowledge first so Rain does not time out.
  res.status(200).json({ ok: true });

  const { resource, action, body } = req.body;
  if (resource === "raindrop_redemption" && action === "completed") {
    try {
      await recordCompletedRedemption(body.id, body.userId);
    } catch (err) {
      console.error(`Failed to record redemption ${body.id}:`, err);
    }
  }
});

app.listen(PORT, () => console.log(`Partner webhook listener running on port ${PORT}`));
```

To debit offchain points for a statement credit, use the [statement-credit endpoint](/docs/rewards/redemptions/statement-credits#partner-managed-offchain).

## Webhook summary

| Webhook                         | When It Fires                                                              | Mode         |
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
| `raindrop_balance.requested`    | Travel portal needs balance                                                | Onchain only |
| `raindrop_redemption.completed` | Booking confirmed, you should burn                                         | Both         |
| `raindrop_redemption.refunded`  | Refund processed. Restore balance for onchain; informational for offchain. | Onchain only |

For onchain partners, `raindrop_redemption.completed` signals that you should debit or burn points in your system. For offchain partners, Rain has already debited the DB, so the webhook is informational.

## Refunds

After the travel portal receives notice from the supplier (for example, a hotel or airline) that a booking was canceled and must be refunded, it sends Rain a message. Rain then re-issues the burned points to the cardholder and marks the redemption `REFUNDED`. How the restored points settle depends on the mode:

| Points Mode                    | Refund Behavior                                                                                     |
| ------------------------------ | --------------------------------------------------------------------------------------------------- |
| **Partner-Managed (Offchain)** | Rain re-issues the points to the database ledger immediately.                                       |
| **Partner-Managed (Onchain)**  | Rain fires the `raindrop_redemption.refunded` webhook so you restore the points in your own system. |
