> This page is for version v2 (default).
> For other versions, use one of these documentation indexes:
> - v2 (default): https://docs.paid.ai/v-2/llms.txt
> - v1: https://docs.paid.ai/v-1/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.paid.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.paid.ai/_mcp/server.

# Authentication and embedding

> Set up JWT-based authentication, embed value receipts, and configure login redirects

Private value receipts require a signed JWT for access. This page covers how to set up the signing key, mint tokens, embed receipts in your app, and handle login redirects for direct link sharing.

For a conceptual overview of published vs. private visibility, see the [overview](/documentation/value-receipts/overview).

## Prerequisites

* At least one [value receipt](/documentation/value-receipts/overview) created for a customer.
* A backend service that can mint JWTs and keep the signing secret out of the browser.
* A JWT library that supports HS256 in your backend language.
* For direct-link access, a login URL in your application. For embedded access, a refresh URL that can mint replacement tokens.

## Setting up embed authentication

### 1. Generate a signing key

In [**Settings > Embed authentication**](https://app.paid.ai/settings/share-auth), click **Generate key** to create an HS256 signing key. The secret is shown **once**. Copy it and store it securely (e.g. in an environment variable or secret manager).

### 2. Configure your settings

In the same settings page, configure:

| Setting         | Description                                                           |
| --------------- | --------------------------------------------------------------------- |
| **Issuer**      | The `iss` claim in your JWTs. Defaults to `"paid"`.                   |
| **Audience**    | Optional `aud` claim. If set, JWTs must include a matching audience.  |
| **TTL**         | Default token lifetime in seconds (default: 3600).                    |
| **Login URL**   | Where unauthenticated viewers are redirected for direct link sharing. |
| **Refresh URL** | Endpoint the embed SDK calls to silently refresh expired tokens.      |

### Rotating the signing key

You can rotate the signing key at any time from the same settings page. Rotating generates a new secret and invalidates the old one. Any JWTs signed with the previous key will be rejected. After rotating, update the secret in your backend environment.

### Rotating a share token

Each value receipt has its own share token that forms its URL (`https://app.paid.ai/public/value-receipts/{token}`). You can rotate a receipt's share token to invalidate all existing links to it. A new token is generated and old URLs stop working immediately. This is useful if a link was shared with the wrong person or you want to revoke access without unpublishing.

### Revoking all public access

If you need to make all value receipts private at once, for example when first enabling JWT-gated access across your organization, use the **Revoke all** action in embed authentication settings. This unpublishes every value receipt in your organization in a single operation.

## Minting JWTs

Your backend mints JWTs using the signing secret. Never expose the secret to the browser.

#### Python

```python
import jwt
import time
import os

SIGNING_SECRET = os.environ["PAID_SHARE_AUTH_SECRET"]

def mint_value_receipt_token(customer_id, resource_id=None):
    now = int(time.time())
    payload = {
        "iss": "paid",
        "sub": customer_id,            # customer ID or externalId
        "resource": "value-receipt",
        "iat": now,
        "exp": now + 3600,             # 1 hour
    }
    # Optional: scope to a single value receipt
    if resource_id:
        payload["resourceId"] = resource_id

    return jwt.encode(payload, SIGNING_SECRET, algorithm="HS256")
```

#### Node.js

```typescript
import jwt from "jsonwebtoken";

const SIGNING_SECRET = process.env.PAID_SHARE_AUTH_SECRET!;

function mintValueReceiptToken(customerId: string, resourceId?: string) {
  const now = Math.floor(Date.now() / 1000);
  const payload: Record<string, unknown> = {
    iss: "paid",
    sub: customerId,             // customer ID or externalId
    resource: "value-receipt",
    iat: now,
    exp: now + 3600,             // 1 hour
  };
  // Optional: scope to a single value receipt
  if (resourceId) {
    payload.resourceId = resourceId;
  }

  return jwt.sign(payload, SIGNING_SECRET, { algorithm: "HS256" });
}
```

#### Go

```go
import (
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

var signingSecret = mustValueReceiptSigningSecret()

func mustValueReceiptSigningSecret() []byte {
    secret := os.Getenv("PAID_SHARE_AUTH_SECRET")
    if secret == "" {
        panic("PAID_SHARE_AUTH_SECRET must be set")
    }
    return []byte(secret)
}

func mintValueReceiptToken(customerID string, resourceID string) (string, error) {
    now := time.Now()
    claims := jwt.MapClaims{
        "iss":      "paid",
        "sub":      customerID, // customer ID or externalId
        "resource": "value-receipt",
        "iat":      now.Unix(),
        "exp":      now.Add(time.Hour).Unix(), // 1 hour
    }
    // Optional: scope to a single value receipt
    if resourceID != "" {
        claims["resourceId"] = resourceID
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(signingSecret)
}
```

### JWT claims reference

| Claim        | Required | Description                                                            |
| ------------ | -------- | ---------------------------------------------------------------------- |
| `iss`        | Yes      | Issuer. Must match the issuer configured in your embed auth settings.  |
| `sub`        | Yes      | The customer's ID or external ID.                                      |
| `resource`   | Yes      | Must be `"value-receipt"`.                                             |
| `iat`        | Yes      | Issued-at timestamp (seconds since epoch).                             |
| `exp`        | Yes      | Expiry timestamp (seconds since epoch).                                |
| `resourceId` | No       | Value receipt ID or `publicUrlToken`. Omit for customer-scoped access. |
| `aud`        | No       | Audience. Must match if configured in your embed auth settings.        |

### Scoping modes

JWTs support two scoping modes:

* **Resource-scoped**: include `resourceId` in the JWT claims. This grants access to one specific value receipt only. The `resourceId` can be either the value receipt's UUID or its `publicUrlToken`.

* **Customer-scoped**: omit `resourceId` and only include `sub` (the customer's ID or external ID). This grants access to all value receipts belonging to that customer.

> Use resource-scoped tokens when sharing a specific receipt with an end user. Use customer-scoped tokens when embedding multiple receipts in a customer portal where the viewer should see all their receipts.

## Embedding value receipts

Use the `@paid-ai/embed` library to embed value receipts in your application. The library handles iframe creation, secure token delivery via `postMessage`, auto-resizing, and token refresh, so you don't have to manage any of that manually.

```bash
npm install @paid-ai/embed
```

```typescript
import { mountValueReceipt } from "@paid-ai/embed";

mountValueReceipt({
  containerId: "value-receipt-container",
  publicUrlToken: "your-public-url-token",
  getToken: async () => {
    // Fetch a JWT from your backend
    const res = await fetch("/api/mint-share-token");
    const { token } = await res.json();
    return token;
  },
  onTokenExpired: async () => {
    // Mint a fresh JWT when the current one expires
    const res = await fetch("/api/share-token/refresh", { method: "POST" });
    const { token } = await res.json();
    return token;
  },
});
```

The library automatically:

* Creates and mounts the iframe in your container element
* Delivers the JWT securely via `postMessage` (never in URLs or browser history)
* Resizes the iframe to match content height
* Handles token expiry by calling your `onTokenExpired` callback

## Login redirect flow

For direct link sharing (not embeds), private value receipts use a login redirect to authenticate viewers. The login page is **yours**. Paid redirects to it and expects a signed token back. You can use any authentication method you want: your own login form, an OAuth provider like Auth0 or Okta, SSO, or anything else.

### How it works

1. A viewer opens a private value receipt link: `https://app.paid.ai/public/value-receipts/{token}`
2. Since there's no valid token, Paid redirects the viewer to your configured `loginUrl` with a `?redirect=` parameter pointing back to the value receipt
3. **Your login page takes over.** This is a page you host. It can show a login form, redirect to an OAuth provider, check an existing session, or anything else your app normally does to authenticate users
4. Once the viewer is authenticated, your backend identifies which customer they belong to, mints a signed JWT, and redirects them back to the original value receipt URL with `?token={jwt}` appended
5. The value receipt page picks up the token and loads the receipt

### Example: simple session-based login

If your app already has sessions (e.g. the viewer is logged into your dashboard), the login endpoint can skip showing a login screen entirely. Check the session, mint a token, and redirect back:

#### Python

```python
from flask import Flask, request, redirect, session
from urllib.parse import urlparse, quote_plus
import jwt
import time
import os

app = Flask(__name__)
SIGNING_SECRET = os.environ["PAID_SHARE_AUTH_SECRET"]
ALLOWED_REDIRECT_HOSTS = {"app.paid.ai"}

def is_safe_redirect(url):
    parsed = urlparse(url)
    return parsed.scheme == "https" and parsed.netloc in ALLOWED_REDIRECT_HOSTS

@app.route("/auth/share-login")
def share_login():
    redirect_url = request.args.get("redirect")
    if not redirect_url or not is_safe_redirect(redirect_url):
        return "Invalid redirect", 400

    # Check if the viewer is already logged in
    customer_id = session.get("customer_id")
    if not customer_id:
        # Not logged in - show your login page or redirect to your auth provider
        # After login, bring them back to this endpoint with the same ?redirect=
        return redirect(f"/login?next=/auth/share-login?redirect={quote_plus(redirect_url)}")

    # Already authenticated - mint a token and redirect back
    now = int(time.time())
    token = jwt.encode({
        "iss": "paid",
        "sub": customer_id,
        "resource": "value-receipt",
        "iat": now,
        "exp": now + 3600,
    }, SIGNING_SECRET, algorithm="HS256")

    separator = "&" if "?" in redirect_url else "?"
    return redirect(f"{redirect_url}{separator}token={token}")
```

#### Node.js

```typescript
import express from "express";
import jwt from "jsonwebtoken";

const app = express();
const SIGNING_SECRET = process.env.PAID_SHARE_AUTH_SECRET!;
const ALLOWED_REDIRECT_HOSTS = new Set(["app.paid.ai"]);

function isSafeRedirect(url: string): boolean {
  try {
    const parsed = new URL(url);
    return parsed.protocol === "https:" && ALLOWED_REDIRECT_HOSTS.has(parsed.hostname);
  } catch {
    return false;
  }
}

app.get("/auth/share-login", (req, res) => {
  const redirectUrl = req.query.redirect as string;
  if (!redirectUrl || !isSafeRedirect(redirectUrl)) {
    return res.status(400).send("Invalid redirect");
  }

  // Check if the viewer is already logged in
  const customerId = req.session?.customerId;
  if (!customerId) {
    // Not logged in - redirect to your login page or auth provider
    // After login, bring them back to this endpoint with the same ?redirect=
    return res.redirect(
      `/login?next=/auth/share-login?redirect=${encodeURIComponent(redirectUrl)}`
    );
  }

  // Already authenticated - mint a token and redirect back
  const now = Math.floor(Date.now() / 1000);
  const token = jwt.sign(
    {
      iss: "paid",
      sub: customerId,
      resource: "value-receipt",
      iat: now,
      exp: now + 3600,
    },
    SIGNING_SECRET,
    { algorithm: "HS256" }
  );

  const url = new URL(redirectUrl);
  url.searchParams.set("token", token);
  res.redirect(url.toString());
});
```

#### Go

```go
import (
    "net/http"
    "net/url"
)

var allowedRedirectHosts = map[string]bool{"app.paid.ai": true}

func isSafeRedirect(rawURL string) bool {
    parsed, err := url.Parse(rawURL)
    return err == nil && parsed.Scheme == "https" && allowedRedirectHosts[parsed.Host]
}

func shareLogin(w http.ResponseWriter, r *http.Request) {
    redirectURL := r.URL.Query().Get("redirect")
    if redirectURL == "" || !isSafeRedirect(redirectURL) {
        http.Error(w, "Invalid redirect", http.StatusBadRequest)
        return
    }

    // Check if the viewer is already logged in
    customerID, ok := sessionCustomerID(r)
    if !ok {
        // Not logged in - redirect to your login page or auth provider.
        // After login, bring them back to this endpoint with the same ?redirect=.
        next := "/auth/share-login?redirect=" + url.QueryEscape(redirectURL)
        http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusFound)
        return
    }

    // Already authenticated - mint a token and redirect back
    token, err := mintValueReceiptToken(customerID, "")
    if err != nil {
        http.Error(w, "Could not mint token", http.StatusInternalServerError)
        return
    }

    target, err := url.Parse(redirectURL)
    if err != nil {
        http.Error(w, "Invalid redirect", http.StatusBadRequest)
        return
    }
    query := target.Query()
    query.Set("token", token)
    target.RawQuery = query.Encode()

    http.Redirect(w, r, target.String(), http.StatusFound)
}
```

### Example: with an external auth provider (OAuth / SSO)

If you use an external provider like Auth0 or Okta, the flow adds one more redirect hop, but the pattern is the same. Your login endpoint kicks off the OAuth flow, and the callback mints the Paid token:

#### Python

```python
@app.route("/auth/share-login")
def share_login():
    redirect_url = request.args.get("redirect")
    if not redirect_url or not is_safe_redirect(redirect_url):
        return "Invalid redirect", 400

    # Store the Paid redirect URL in session so we can use it after OAuth completes
    session["paid_redirect"] = redirect_url

    # Redirect to your OAuth provider's login
    return redirect(
        f"https://your-auth-provider.com/authorize"
        f"?client_id=YOUR_CLIENT_ID"
        f"&redirect_uri=https://yourapp.com/auth/callback"
        f"&response_type=code"
    )

@app.route("/auth/callback")
def auth_callback():
    # Exchange the OAuth code for user info (standard OAuth flow)
    customer_id = exchange_code_for_customer_id(request.args["code"])

    # Retrieve the original Paid redirect URL
    redirect_url = session.pop("paid_redirect")

    # Mint a Paid share token and redirect back
    now = int(time.time())
    token = jwt.encode({
        "iss": "paid",
        "sub": customer_id,
        "resource": "value-receipt",
        "iat": now,
        "exp": now + 3600,
    }, SIGNING_SECRET, algorithm="HS256")

    separator = "&" if "?" in redirect_url else "?"
    return redirect(f"{redirect_url}{separator}token={token}")
```

#### Node.js

```typescript
app.get("/auth/share-login", (req, res) => {
  const redirectUrl = req.query.redirect as string;
  if (!redirectUrl || !isSafeRedirect(redirectUrl)) {
    return res.status(400).send("Invalid redirect");
  }

  // Store the Paid redirect URL in session so we can use it after OAuth completes
  req.session.paidRedirect = redirectUrl;

  // Redirect to your OAuth provider's login
  res.redirect(
    `https://your-auth-provider.com/authorize` +
    `?client_id=YOUR_CLIENT_ID` +
    `&redirect_uri=https://yourapp.com/auth/callback` +
    `&response_type=code`
  );
});

app.get("/auth/callback", async (req, res) => {
  // Exchange the OAuth code for user info (standard OAuth flow)
  const customerId = await exchangeCodeForCustomerId(req.query.code as string);

  // Retrieve the original Paid redirect URL
  const redirectUrl = req.session.paidRedirect;
  delete req.session.paidRedirect;

  // Mint a Paid share token and redirect back
  const now = Math.floor(Date.now() / 1000);
  const token = jwt.sign(
    {
      iss: "paid",
      sub: customerId,
      resource: "value-receipt",
      iat: now,
      exp: now + 3600,
    },
    SIGNING_SECRET,
    { algorithm: "HS256" }
  );

  const url = new URL(redirectUrl);
  url.searchParams.set("token", token);
  res.redirect(url.toString());
});
```

#### Go

```go
func shareLoginOAuth(w http.ResponseWriter, r *http.Request) {
    redirectURL := r.URL.Query().Get("redirect")
    if redirectURL == "" || !isSafeRedirect(redirectURL) {
        http.Error(w, "Invalid redirect", http.StatusBadRequest)
        return
    }

    // Store the Paid redirect URL in session so we can use it after OAuth completes
    savePaidRedirect(w, r, redirectURL)

    // Redirect to your OAuth provider's login
    authURL, _ := url.Parse("https://your-auth-provider.com/authorize")
    query := authURL.Query()
    query.Set("client_id", "YOUR_CLIENT_ID")
    query.Set("redirect_uri", "https://yourapp.com/auth/callback")
    query.Set("response_type", "code")
    authURL.RawQuery = query.Encode()

    http.Redirect(w, r, authURL.String(), http.StatusFound)
}

func authCallback(w http.ResponseWriter, r *http.Request) {
    // Exchange the OAuth code for user info (standard OAuth flow)
    customerID, err := exchangeCodeForCustomerID(r.URL.Query().Get("code"))
    if err != nil {
        http.Error(w, "Authentication failed", http.StatusUnauthorized)
        return
    }

    // Retrieve the original Paid redirect URL
    redirectURL, err := popPaidRedirect(r)
    if err != nil {
        http.Error(w, "Missing redirect", http.StatusBadRequest)
        return
    }
    if !isSafeRedirect(redirectURL) {
        http.Error(w, "Invalid redirect", http.StatusBadRequest)
        return
    }

    // Mint a Paid share token and redirect back
    token, err := mintValueReceiptToken(customerID, "")
    if err != nil {
        http.Error(w, "Could not mint token", http.StatusInternalServerError)
        return
    }

    target, err := url.Parse(redirectURL)
    if err != nil {
        http.Error(w, "Invalid redirect", http.StatusBadRequest)
        return
    }
    query := target.Query()
    query.Set("token", token)
    target.RawQuery = query.Encode()

    http.Redirect(w, r, target.String(), http.StatusFound)
}
```

### Token refresh endpoint

For embedded value receipts, implement a refresh endpoint that the embed can call when a token expires:

#### Python

```python
@app.route("/api/share-token/refresh", methods=["POST"])
def refresh_share_token():
    # Validate the viewer's session
    customer_id = get_authenticated_customer_id()

    now = int(time.time())
    token = jwt.encode({
        "iss": "paid",
        "sub": customer_id,
        "resource": "value-receipt",
        "iat": now,
        "exp": now + 3600,
    }, SIGNING_SECRET, algorithm="HS256")

    return {"token": token}
```

#### Node.js

```typescript
app.post("/api/share-token/refresh", (req, res) => {
  // Validate the viewer's session
  const customerId = getAuthenticatedCustomerId(req);

  const now = Math.floor(Date.now() / 1000);
  const token = jwt.sign(
    {
      iss: "paid",
      sub: customerId,
      resource: "value-receipt",
      iat: now,
      exp: now + 3600,
    },
    SIGNING_SECRET,
    { algorithm: "HS256" }
  );

  res.json({ token });
});
```

#### Go

```go
import "encoding/json"

type refreshShareTokenRequest struct {
    ResourceID string `json:"resourceId"`
}

func refreshShareToken(w http.ResponseWriter, r *http.Request) {
    // Validate the viewer's session
    customerID, err := getAuthenticatedCustomerID(r)
    if err != nil {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    var body refreshShareTokenRequest
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    if body.ResourceID == "" {
        http.Error(w, "Missing resourceId", http.StatusBadRequest)
        return
    }

    token, err := mintValueReceiptToken(customerID, body.ResourceID)
    if err != nil {
        http.Error(w, "Could not mint token", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(map[string]string{"token": token})
}
```