Authentication and embedding

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.


Setting up embed authentication

1. Generate a signing key

In Settings > Embed authentication, 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:

SettingDescription
IssuerThe iss claim in your JWTs. Defaults to "paid".
AudienceOptional aud claim. If set, JWTs must include a matching audience.
TTLDefault token lifetime in seconds (default: 3600).
Login URLWhere unauthenticated viewers are redirected for direct link sharing.
Refresh URLEndpoint 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.

1import jwt
2import time
3import os
4
5SIGNING_SECRET = os.environ["PAID_SHARE_AUTH_SECRET"]
6
7def mint_value_receipt_token(customer_id, resource_id=None):
8 now = int(time.time())
9 payload = {
10 "iss": "paid",
11 "sub": customer_id, # customer ID or externalId
12 "resource": "value-receipt",
13 "iat": now,
14 "exp": now + 3600, # 1 hour
15 }
16 # Optional: scope to a single value receipt
17 if resource_id:
18 payload["resourceId"] = resource_id
19
20 return jwt.encode(payload, SIGNING_SECRET, algorithm="HS256")

JWT claims reference

ClaimRequiredDescription
issYesIssuer — must match the issuer configured in your embed auth settings.
subYesThe customer’s ID or external ID.
resourceYesMust be "value-receipt".
iatYesIssued-at timestamp (seconds since epoch).
expYesExpiry timestamp (seconds since epoch).
resourceIdNoValue receipt ID or publicUrlToken. Omit for customer-scoped access.
audNoAudience — 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.

$npm install @paid-ai/embed
1import { mountValueReceipt } from "@paid-ai/embed";
2
3mountValueReceipt({
4 containerId: "value-receipt-container",
5 publicUrlToken: "your-public-url-token",
6 getToken: async () => {
7 // Fetch a JWT from your backend
8 const res = await fetch("/api/mint-share-token");
9 const { token } = await res.json();
10 return token;
11 },
12 onTokenExpired: async () => {
13 // Mint a fresh JWT when the current one expires
14 const res = await fetch("/api/share-token/refresh", { method: "POST" });
15 const { token } = await res.json();
16 return token;
17 },
18});

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 just 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 — just check the session, mint a token, and redirect back:

1from flask import Flask, request, redirect, session
2from urllib.parse import urlparse, quote_plus
3import jwt
4import time
5import os
6
7app = Flask(__name__)
8SIGNING_SECRET = os.environ["PAID_SHARE_AUTH_SECRET"]
9ALLOWED_REDIRECT_HOSTS = {"app.paid.ai"}
10
11def is_safe_redirect(url):
12 parsed = urlparse(url)
13 return parsed.scheme == "https" and parsed.netloc in ALLOWED_REDIRECT_HOSTS
14
15@app.route("/auth/share-login")
16def share_login():
17 redirect_url = request.args.get("redirect")
18 if not redirect_url or not is_safe_redirect(redirect_url):
19 return "Invalid redirect", 400
20
21 # Check if the viewer is already logged in
22 customer_id = session.get("customer_id")
23 if not customer_id:
24 # Not logged in — show your login page or redirect to your auth provider
25 # After login, bring them back to this endpoint with the same ?redirect=
26 return redirect(f"/login?next=/auth/share-login?redirect={quote_plus(redirect_url)}")
27
28 # Already authenticated — mint a token and redirect back
29 now = int(time.time())
30 token = jwt.encode({
31 "iss": "paid",
32 "sub": customer_id,
33 "resource": "value-receipt",
34 "iat": now,
35 "exp": now + 3600,
36 }, SIGNING_SECRET, algorithm="HS256")
37
38 separator = "&" if "?" in redirect_url else "?"
39 return redirect(f"{redirect_url}{separator}token={token}")

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:

1@app.route("/auth/share-login")
2def share_login():
3 redirect_url = request.args.get("redirect")
4 if not redirect_url or not is_safe_redirect(redirect_url):
5 return "Invalid redirect", 400
6
7 # Store the Paid redirect URL in session so we can use it after OAuth completes
8 session["paid_redirect"] = redirect_url
9
10 # Redirect to your OAuth provider's login
11 return redirect(
12 f"https://your-auth-provider.com/authorize"
13 f"?client_id=YOUR_CLIENT_ID"
14 f"&redirect_uri=https://yourapp.com/auth/callback"
15 f"&response_type=code"
16 )
17
18@app.route("/auth/callback")
19def auth_callback():
20 # Exchange the OAuth code for user info (standard OAuth flow)
21 customer_id = exchange_code_for_customer_id(request.args["code"])
22
23 # Retrieve the original Paid redirect URL
24 redirect_url = session.pop("paid_redirect")
25
26 # Mint a Paid share token and redirect back
27 now = int(time.time())
28 token = jwt.encode({
29 "iss": "paid",
30 "sub": customer_id,
31 "resource": "value-receipt",
32 "iat": now,
33 "exp": now + 3600,
34 }, SIGNING_SECRET, algorithm="HS256")
35
36 separator = "&" if "?" in redirect_url else "?"
37 return redirect(f"{redirect_url}{separator}token={token}")

Token refresh endpoint

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

1@app.route("/api/share-token/refresh", methods=["POST"])
2def refresh_share_token():
3 # Validate the viewer's session
4 customer_id = get_authenticated_customer_id()
5
6 now = int(time.time())
7 token = jwt.encode({
8 "iss": "paid",
9 "sub": customer_id,
10 "resource": "value-receipt",
11 "iat": now,
12 "exp": now + 3600,
13 }, SIGNING_SECRET, algorithm="HS256")
14
15 return {"token": token}