Agent skill

Pinme Uniwebpay

by glitternetwork in glitternetwork/pinme

A skill your agent uses when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout…

MITAuto-check passedBackend & APIs

Install Pinme Uniwebpay

skills CLI
$ npx skills add glitternetwork/pinme --skill pinme-uniwebpay -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install glitternetwork/pinme pinme-uniwebpay --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/glitternetwork/pinme.git skills-src && mkdir -p .claude/skills && cp -r skills-src/skills/pinme-uniwebpay .claude/skills/pinme-uniwebpay && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
pinme-uniwebpay
GitHub stars
3.7k
Used in
1 other repo
Token cost
~7.3k tokens
SKILL.md length
1,786 words
Files
1
Skills in repo
7
Repo updated
First seen
Licence
MIT

At a glance

A skill your agent uses when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout…

  • Reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links
  • SKILL.md covers Environment Binding Contract, SDK Client, Choosing an Integration Path and Payment Methods and Currency…, plus 7 more sections
  • Reaches apiskill.uniwebpay.com and skill.uniwebpay.com; needs UNIWEB_WEBHOOK_SECRET and UNIWEB_SECRET
  • Products/prices

What it does

Pinme Uniwebpay is an agent skill from glitternetwork/pinme. Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.

Its SKILL.md is about 7.3k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Backend & APIs, covering Webhooks and Payments and billing. It works with Cloudflare Workers and TypeScript. The repository describes itself as: Deploy Your Frontend in a Single Command. Claude Code Skills supported. The licence is MIT.

When your agent uses it

  • Reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links
  • Products/prices
  • Checkout sessions
  • Payment status reads

Example prompts

  • “/pinme-uniwebpay”

Requirements

  • A credential in UNIWEB_SECRET
  • A credential in UNIWEB_WEBHOOK_SECRET

What it can do on your machine

Read from SKILL.md and the folder at commit 7822b05. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    No scripts in the folder and no shell commands in SKILL.md (its code samples are typescript and sql).

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Hosts in commands or code, which the agent is likely to contact:

    • apiskill.uniwebpay.com
    • skill.uniwebpay.com

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names these keys or tokens, usually read from environment variables:

    • UNIWEB_WEBHOOK_SECRET
    • UNIWEB_SECRET
    • API_KEY

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Pinme Uniwebpay loads about 7.3k tokens when it runs. Until then it costs about 80 tokens; SKILL.md has 1,786 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~80
When it runs · the whole SKILL.md, loaded when a task matches
~7.3k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from glitternetwork/pinme at commit 7822b05, republished under its MIT licence (© glitternetwork). 1,786 words, ~7,256 tokens.

Download SKILL.mdSave it as .claude/skills/pinme-uniwebpay/SKILL.md (or your agent's skills folder).
name
pinme-uniwebpay
description
Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.

PinMe UniwebPay Payment Integration

Guides writing payment services in a PinMe Worker (Cloudflare Worker TypeScript) that call UniwebPay directly through @uniwebpay/sdk.

Core model: PinMe provisions the UniwebPay wallet and keys per PinMe user (not per project) and injects UNIWEB_* environment bindings at Worker deploy time; Worker code calls UniwebPay directly with the SDK — it does not go through PinMe payment proxy routes, and it must not call the legacy VibeCash APIs.

Environment Binding Contract

typescript
export interface Env {
  UNIWEB_SECRET: string;           // PinMe-provisioned sk_server_ key (server-side only)
  UNIWEB_WEBHOOK_SECRET?: string;  // wallet-level whsec_, used to verify webhook signatures
  UNIWEB_API_URL?: string;         // UniwebPay API endpoint override (default https://apiskill.uniwebpay.com)
  UNIWEB_PAY_URL?: string;         // UniwebPay checkout host override (default https://skill.uniwebpay.com)
  UNIWEB_WALLET_ID?: string;       // user-level wallet id (wal_), diagnostics/reconciliation only
  WORKER_URL?: string;             // this project's public URL: https://{projectName}.{platform api domain}
  PROJECT_NAME?: string;           // PinMe project name
  DB?: D1Database;                 // project D1 (if enabled)
}

Injection rules (metadata is rebuilt server-side by PinMe at deploy time; client-supplied bindings are ignored):

  • The UNIWEB_* bindings are injected only after the user's UniwebPay credentials have been provisioned. Newly created projects are provisioned automatically and get them immediately; existing projects must be redeployed after enabling UniwebPay or rotating keys to pick up new bindings.
  • WORKER_URL, PROJECT_NAME, API_KEY, DB and other base bindings are injected on every deploy, independent of UniwebPay.
  • All projects owned by the same PinMe user share one wallet, one sk_server_, and one whsec_.
  • PinMe never gives the full wallet secret (sk_live_) to a Worker. Do not ask the user for it, and do not put it in code, wrangler.toml, .dev.vars, responses, logs, D1, or frontend bundles.
  • If UNIWEB_SECRET is missing at runtime, the user has not enabled UniwebPay or has not redeployed — tell the user to enable it and redeploy; never fabricate a value.

SDK Client

Always instantiate on the server side (the Worker); the SDK throws when run in a browser:

typescript
import Uniweb from "@uniwebpay/sdk";

function uniwebClient(env: Env): Uniweb {
  return new Uniweb(env.UNIWEB_SECRET, {
    baseUrl: env.UNIWEB_API_URL,
    payUrl: env.UNIWEB_PAY_URL,
  });
}
  • The constructor's first positional argument is the key (must have an sk_server_ or sk_live_ prefix); the second is optional options: { baseUrl?, payUrl?, timeout? (default 30s), maxRetries? (default 2) }.
  • The SDK auto-retries only GET/DELETE on 429/5xx; POST/PATCH are never retried (avoids duplicate charges).
  • Install @uniwebpay/sdk only when Worker code imports it; pick the package manager from the project's existing lockfile.

Choosing an Integration Path

ScenarioApproachReturns
Fixed-amount one-time collectionuniweb.links.create(...)Permanent, reusable /p/ link (one-time payments only)
Stable product catalogproducts.create + prices.create once, store the priceIdPrice carries a permanent paymentUrl (/buy/ link)
Dynamic cart/orderReuse or create a price, then uniweb.checkout.create(...)session.url — one-time, expires in 24 hours
SubscriptionsRecurring price + checkout.create({ mode: "subscription" }) or subscriptions.createSame as above
Server-side payment status checkspayments.get / listServer routes only

Amounts are always integer minor units (cents). Default currency convention is SGD unless the app has a stronger existing convention. Do not create a new product/price on every page view — create stable catalog items once and persist the priceId.

Payment Methods and Currency Rules

MethodSupported currencies
cardSGD, USD, EUR, GBP, JPY, CNY, HKD, AUD, MYR, THB (minimum 10 minor units)
wechatSGD only
alipaySGD only
paynowSGD only
  • The QR methods (wechat/alipay/paynow) all support SGD only — never generate "CNY via WeChat/Alipay" code.
  • Subscriptions (recurring / mode: "subscription") use card only.
  • When paymentMethodTypes is omitted, the server picks sensible defaults for the currency; when passed explicitly, validate user input against the table above first.

SDK Surface Quick Reference

The surface below is verified against source. All parameter fields are camelCase (priceId, webhookUrl, startingAfter, …); the SDK handles wire-level conversion itself. list() returns { data: T[], hasMore: boolean }; listAll() is an async generator available on products, prices, payments, customers, subscriptions, and links (not on checkout or refunds).

Products (webhookUrl is the per-product callback override):

typescript
await uniweb.products.create({ name, description?, webhookUrl?, metadata? });
await uniweb.products.list({ limit?, startingAfter? });
await uniweb.products.get(productId);
await uniweb.products.update(productId, { name?, description?, webhookUrl?, active?, metadata? });
await uniweb.products.del(productId);
for await (const product of uniweb.products.listAll()) {}

Prices (the returned price carries a permanent paymentUrl; deactivate takes it off sale):

typescript
await uniweb.prices.create({
  productId,
  amount,        // integer minor units
  currency,      // e.g. "SGD"
  type,          // "one_time" | "recurring"
  interval?,     // "day" | "week" | "month" | "year"; recurring only
  intervalCount?,
  trialPeriodDays?,
  metadata?,
});
await uniweb.prices.list({ productId?, limit?, startingAfter? });
await uniweb.prices.get(priceId);
await uniweb.prices.update(priceId, { active });
await uniweb.prices.activate(priceId);
await uniweb.prices.deactivate(priceId);
for await (const price of uniweb.prices.listAll({ productId? })) {}

Checkout sessions (do not accept webhookUrl — events resolve through the price → product → wallet chain; the URL is one-time and expires after 24 hours):

typescript
await uniweb.checkout.create({
  mode,           // "payment" | "subscription"
  lineItems: [{ priceId, quantity }],
  successUrl?,
  cancelUrl?,
  customerEmail?,
  customerId?,
  trialPeriodDays?,
  paymentMethodTypes?, // ["card", "wechat", "alipay", "paynow"]
  metadata?,
});
await uniweb.checkout.list({ limit?, startingAfter? });
await uniweb.checkout.get(checkoutSessionId);

Payments (for server-side status checks; only mark a local order paid when amount, currency, metadata, and order state all match expectations):

typescript
await uniweb.payments.create({ amount, currency, customerId?, metadata? });
await uniweb.payments.list({ status?, customerId?, limit?, startingAfter? });
await uniweb.payments.get(paymentId, { gateway? });
await uniweb.payments.listRefunds(paymentId);
await uniweb.payments.sync(paymentId);
await uniweb.payments.void(paymentId);
for await (const payment of uniweb.payments.listAll({ status?, customerId? })) {}

Refunds (no list/listAll — use payments.listRefunds):

typescript
await uniweb.refunds.create({ paymentId, amount?, reason?, offlineRefundFlag? });
await uniweb.refunds.get(refundId, { gateway? });

Customers:

typescript
await uniweb.customers.create({ email, name?, metadata? });
await uniweb.customers.list({ email?, limit?, startingAfter? });
await uniweb.customers.get(customerId);
await uniweb.customers.update(customerId, { email?, name?, metadata? });
await uniweb.customers.del(customerId);
for await (const customer of uniweb.customers.listAll({ email? })) {}

Subscriptions (states include trialing / active / past_due / unpaid / canceled; update access only from verified webhooks or a trusted server-side reconciliation job):

typescript
await uniweb.subscriptions.create({ customerId, priceId, paymentMethodId?, trialPeriodDays?, metadata? });
await uniweb.subscriptions.list({ customerId?, status?, limit?, startingAfter? });
await uniweb.subscriptions.get(subscriptionId);
await uniweb.subscriptions.update(subscriptionId, { cancelAtPeriodEnd? });
await uniweb.subscriptions.cancel(subscriptionId); // cancel immediately
await uniweb.subscriptions.resume(subscriptionId); // undo cancelAtPeriodEnd
for await (const subscription of uniweb.subscriptions.listAll({ customerId?, status? })) {}

Payment links (permanent reusable /p/ links, one-time collection only; webhookUrl is the per-link callback override):

typescript
await uniweb.links.create({
  amount,
  currency,
  name?,
  description?,
  successUrl?,
  cancelUrl?,
  webhookUrl?,
  paymentMethodTypes?,
  metadata?,
});
await uniweb.links.list({ limit?, startingAfter? });
await uniweb.links.get(paymentLinkId);
await uniweb.links.update(paymentLinkId, { name?, description?, successUrl?, cancelUrl?, webhookUrl?, active? });
await uniweb.links.deactivate(paymentLinkId);
for await (const link of uniweb.links.listAll()) {}

Wallet and wallet-level webhook configuration (danger zone: affects the wallet shared by ALL of the user's projects):

typescript
await uniweb.wallet.current();
await uniweb.wallet.update({ merchantName?, merchantCity?, merchantCountry?, webhookUrl? });
await uniweb.webhooks.set(url);       // overwrites the wallet-level callback URL
await uniweb.webhooks.info();
await uniweb.webhooks.remove();
await uniweb.webhooks.rollSecret();   // rotates the shared whsec_

Ordinary project routes must not call webhooks.set / remove / rollSecret or wallet.update — they mutate the wallet callback fallback and signing secret shared across all of the user's projects. Generate them only when the user explicitly asks for wallet administration and the route has project/admin-level authorization. Same for refunds, subscription mutations, payouts, KYC, and bank account APIs: generate only when the user explicitly requests that business flow and the code has validation, persistence, and authorization.

Webhook Integration

Event delivery precedence: per-link webhookUrl > per-product webhookUrl > wallet-level fallback. The signing secret is always the wallet-level whsec_ (i.e. env.UNIWEB_WEBHOOK_SECRET).

PinMe sets a managed fallback callback URL on the wallet, but it exists only to obtain and preserve the signing secret — PinMe's server discards events it receives there (204); it never forwards them to the Worker. Business events must therefore set this project's webhookUrl explicitly on the resource that creates the payment:

  • Payment links: pass webhookUrl on links.create.
  • Checkout sessions: checkout.create has no webhookUrl field; events route through the price's product — set webhookUrl on products.create (or on the reused product).

Rules for building the webhookUrl:

  • Keep the callback path in a single constant (e.g. const WEBHOOK_PATH = "/api/pay/webhook") shared by the router and the webhookUrl construction, so a path mismatch can't 404 the callbacks and leave orders stuck in pending.
  • Use env.WORKER_URL as the base: new URL(WEBHOOK_PATH, env.WORKER_URL).toString(). It is the only public address available at runtime (the platform subdomain); the user's custom domain is not in env. Prefer it over request.url (the current request's host is not necessarily the deployed address), and non-HTTP contexts (cron/queue) have no request at all — fail loudly if it's missing rather than emitting a broken URL.
  • Local dev has no WORKER_URL; a request.url fallback resolves to localhost, which UniwebPay cannot reach. To test webhooks locally, expose the Worker through a tunnel (cloudflared / ngrok).
  • webhookUrl must be HTTPS. Put no secrets or trust-bearing data in the URL query — carry correlation like orderId in metadata, and verify identity from the signature plus metadata (a query string can be forged).
Show full SKILL.md (784 more words)Show less
Verification and Handling
typescript
import { verifyWebhook } from "@uniwebpay/sdk";

const rawBody = await request.text();              // read the raw body exactly once
const event = await verifyWebhook(                  // note: async
  rawBody,
  request.headers.get("uniweb-Signature") || "",   // format: t=<unix>,v1=<hex>
  env.UNIWEB_WEBHOOK_SECRET,
);
  • verifyWebhook(rawBody, signature, secret) returns Promise<WebhookEvent>; signature timestamp tolerance is ±5 minutes; it throws on failure.
  • Event shape: { id: "evt_...", type, created, data: { object, productId?, priceId?, productName? } }. The business object is in event.data.object; correlation like orderId is in event.data.object.metadata.
  • Event types verified as actually delivered: payment.succeeded / payment.failed / payment.refunded / payment.partially_refunded, refund.succeeded / refund.failed / refund.abandoned, checkout.session.completed / checkout.session.expired, subscription.created / subscription.renewed / subscription.past_due / subscription.unpaid / subscription.trial_ending / subscription.canceled.

Handling rules:

  • The webhook route must bypass the project's own auth. UniwebPay callbacks carry only uniweb-Signature (plus uniweb-Event-Id / uniweb-Timestamp), never the project API_KEY. If a global auth guard wraps all routes, exempt WEBHOOK_PATH — trust comes solely from signature verification. Otherwise callbacks get 401/403 and orders never fulfill.
  • Return 400 for invalid signatures (so UniwebPay stops pointless retries); return 500 for temporary processing failures (so it retries).
  • Enforce idempotency with event.id (combined with payment id / checkout session id / local order id).
  • Before fulfillment, verify amount, currency, metadata, and current order state.
  • Respond within 10 seconds; delivery does not follow redirects, so do not put the webhook route behind a redirect. The first delivery is immediate; on failure, retries follow at 5-minute, 30-minute, 2-hour, and 12-hour intervals, up to 6 attempts before the event is marked failed.
  • Keep UNIWEB_WEBHOOK_SECRET optional in TypeScript — a project can exist before provisioning or redeploy; return 501 with a hint when it's missing at runtime.

Security Rules

  • No Uniweb secret (UNIWEB_SECRET, UNIWEB_WEBHOOK_SECRET) may appear in responses, logs, metadata, test snapshots, D1, source code, committed wrangler.toml / .dev.vars, or browser code. For local dev, use an uncommitted .dev.vars only.
  • Do not import the SDK in browser-side code; do not use process.env in Cloudflare Workers — use the env argument.
  • Do not call legacy VibeCash APIs or PinMe payment proxy routes; do not call PinMe payment APIs with X-API-Key for UniwebPay collection — the Worker calls UniwebPay directly through the SDK.
  • successUrl / cancelUrl are UX redirects only. Never fulfill based on a browser redirect; grant access only after verified webhook processing (or another explicit server-side verification).
  • Validate user input before SDK calls: amount (integer minor units), currency (ISO 4217 and within the payment-method constraints), quantity, product/price IDs, payment method types, order ownership, and metadata shape.

Persistence Guidance (D1)

Add tables/migrations only when the project already uses D1 or the user asks for persistence. For order flows, store at least: local order id, the corresponding Uniweb link/session/payment/subscription id, amount and currency, status, timestamps, and processed webhook event ids (for idempotency). Never store secrets.

sql
CREATE TABLE IF NOT EXISTS orders (
  order_id TEXT PRIMARY KEY,
  checkout_session_id TEXT UNIQUE,
  status TEXT NOT NULL DEFAULT 'pending',
  amount_cents INTEGER NOT NULL,
  currency TEXT NOT NULL,
  paid_at INTEGER,
  created_at INTEGER NOT NULL,
  updated_at INTEGER
);

CREATE TABLE IF NOT EXISTS payment_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  order_id TEXT,
  raw_payload TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

Worker Reference Implementation

typescript
import Uniweb, { verifyWebhook } from "@uniwebpay/sdk";

export interface Env {
  UNIWEB_SECRET: string;
  UNIWEB_WEBHOOK_SECRET?: string;
  UNIWEB_API_URL?: string;
  UNIWEB_PAY_URL?: string;
  UNIWEB_WALLET_ID?: string;
  WORKER_URL?: string;
  PROJECT_NAME?: string;
  DB?: D1Database;
}

type PaymentMethod = "card" | "wechat" | "alipay" | "paynow";

const VALID_PAYMENT_METHODS = new Set<PaymentMethod>(["card", "wechat", "alipay", "paynow"]);
const CARD_CURRENCIES = new Set(["SGD", "USD", "EUR", "GBP", "JPY", "CNY", "HKD", "AUD", "MYR", "THB"]);

// Single source of truth for the webhook path: shared by the router and the
// webhookUrl construction so the address sent to UniwebPay always matches the
// route the Worker actually serves.
const WEBHOOK_PATH = "/api/pay/webhook";

function uniwebClient(env: Env): Uniweb {
  return new Uniweb(env.UNIWEB_SECRET, {
    baseUrl: env.UNIWEB_API_URL,
    payUrl: env.UNIWEB_PAY_URL,
  });
}

function json(data: unknown, init: ResponseInit = {}): Response {
  return Response.json(data, init);
}

function assertAmountCents(value: unknown): number {
  const amount = Number(value);
  if (!Number.isInteger(amount) || amount < 10) {
    throw new Error("amountCents must be an integer minor-unit amount >= 10");
  }
  return amount;
}

function normalizeCurrency(value: unknown): string {
  const currency = String(value || "SGD").toUpperCase();
  if (!/^[A-Z]{3}$/.test(currency)) throw new Error("currency must be an ISO 4217 code");
  return currency;
}

// QR methods (wechat/alipay/paynow) support SGD only; card supports 10 currencies.
function defaultPaymentMethods(currency: string): PaymentMethod[] {
  if (currency === "SGD") return ["card", "wechat", "alipay", "paynow"];
  if (CARD_CURRENCIES.has(currency)) return ["card"];
  throw new Error(`unsupported currency: ${currency}`);
}

function normalizePaymentMethods(value: unknown, currency: string): PaymentMethod[] {
  const requested = Array.isArray(value) && value.length > 0 ? value : defaultPaymentMethods(currency);
  const methods = requested.map((m) => String(m).toLowerCase() as PaymentMethod);
  for (const method of methods) {
    if (!VALID_PAYMENT_METHODS.has(method)) {
      throw new Error("paymentMethodTypes contains an unsupported method");
    }
    if (method !== "card" && currency !== "SGD") {
      throw new Error(`${method} only supports SGD payments`);
    }
    if (method === "card" && !CARD_CURRENCIES.has(currency)) {
      throw new Error(`card does not support ${currency}`);
    }
  }
  return Array.from(new Set(methods));
}

// Prefer the PinMe-injected WORKER_URL (the project's platform subdomain, the
// only public address available at runtime). request.url is only a fallback for
// older deploys; cron/queue contexts have no request, so WORKER_URL is required.
function projectWebhookUrl(env: Env, request?: Request): string {
  const base = env.WORKER_URL || request?.url;
  if (!base) throw new Error("WORKER_URL binding is missing");
  return new URL(WEBHOOK_PATH, base).toString();
}

// ---- One-time collection: payment link ----

async function createPaymentLink(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });

  let input: any;
  try {
    input = await request.json();
  } catch {
    return json({ error: "invalid JSON body" }, { status: 400 });
  }

  let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];
  try {
    amount = assertAmountCents(input.amountCents);
    currency = normalizeCurrency(input.currency);
    paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
  } catch (err) {
    return json({ error: (err as Error).message }, { status: 400 });
  }

  const orderId = input.orderId || crypto.randomUUID();
  const uniweb = uniwebClient(env);

  try {
    const link = await uniweb.links.create({
      amount,
      currency,
      name: input.name || "Payment",
      description: input.description,
      successUrl: input.successUrl,
      cancelUrl: input.cancelUrl,
      webhookUrl: projectWebhookUrl(env, request),
      paymentMethodTypes,
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });
    return json({ orderId, linkId: link.id, url: link.url });
  } catch {
    return json({ error: "failed to create payment link" }, { status: 502 });
  }
}

// ---- Dynamic orders: checkout session ----
// Note: checkout.create has no webhookUrl; the callback is set on the product.
// Stable catalog items should create the product/price once and persist the
// priceId — do not create new ones on every request.

async function createCheckoutSession(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });

  let input: any;
  try {
    input = await request.json();
  } catch {
    return json({ error: "invalid JSON body" }, { status: 400 });
  }

  let amount: number, currency: string, paymentMethodTypes: PaymentMethod[];
  try {
    amount = assertAmountCents(input.amountCents);
    currency = normalizeCurrency(input.currency);
    paymentMethodTypes = normalizePaymentMethods(input.paymentMethodTypes, currency);
  } catch (err) {
    return json({ error: (err as Error).message }, { status: 400 });
  }

  const orderId = input.orderId || crypto.randomUUID();
  const quantity = Math.max(1, Math.floor(Number(input.quantity || 1)));
  const uniweb = uniwebClient(env);

  try {
    const product = await uniweb.products.create({
      name: input.productName,
      webhookUrl: projectWebhookUrl(env, request),
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });
    const price = await uniweb.prices.create({
      productId: product.id,
      amount,
      currency,
      type: "one_time",
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });
    const session = await uniweb.checkout.create({
      mode: "payment",
      lineItems: [{ priceId: price.id, quantity }],
      successUrl: input.successUrl,
      cancelUrl: input.cancelUrl,
      customerEmail: input.customerEmail,
      paymentMethodTypes,
      metadata: { orderId, projectName: env.PROJECT_NAME },
    });

    if (env.DB) {
      await env.DB.prepare(
        `INSERT INTO orders(order_id, checkout_session_id, status, amount_cents, currency, created_at)
         VALUES (?, ?, 'pending', ?, ?, ?)
         ON CONFLICT(order_id) DO UPDATE SET checkout_session_id = excluded.checkout_session_id`,
      )
        .bind(orderId, session.id, amount * quantity, currency, Math.floor(Date.now() / 1000))
        .run();
    }

    return json({ orderId, checkoutSessionId: session.id, url: session.url });
  } catch {
    return json({ error: "failed to create checkout session" }, { status: 502 });
  }
}

// ---- Webhook handling ----

async function handleUniwebWebhook(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") return json({ error: "method not allowed" }, { status: 405 });
  if (!env.UNIWEB_WEBHOOK_SECRET) {
    return json({ error: "webhook verification is not configured" }, { status: 501 });
  }

  const rawBody = await request.text();
  let event: Awaited<ReturnType<typeof verifyWebhook>>;
  try {
    event = await verifyWebhook(
      rawBody,
      request.headers.get("uniweb-Signature") || "",
      env.UNIWEB_WEBHOOK_SECRET,
    );
  } catch {
    return json({ error: "invalid signature" }, { status: 400 });
  }

  try {
    if (env.DB) {
      const object = event.data.object as { metadata?: Record<string, unknown> };
      const orderId = String(object?.metadata?.orderId || "");
      const now = Math.floor(Date.now() / 1000);

      // event.id idempotency: duplicate deliveries become no-ops
      await env.DB.prepare(
        `INSERT INTO payment_events(event_id, event_type, order_id, raw_payload, created_at)
         VALUES (?, ?, ?, ?, ?)
         ON CONFLICT(event_id) DO NOTHING`,
      )
        .bind(event.id, event.type, orderId, rawBody, now)
        .run();

      if (event.type === "payment.succeeded" && orderId) {
        // Production code should verify amount/currency/order state here before fulfilling
        await env.DB.prepare(
          `UPDATE orders SET status = 'paid', paid_at = ?, updated_at = ? WHERE order_id = ? AND status != 'paid'`,
        )
          .bind(now, now, orderId)
          .run();
      }
    }
    return json({ ok: true });
  } catch {
    // 500 makes UniwebPay retry on the 5m/30m/2h/12h schedule
    return json({ error: "processing error" }, { status: 500 });
  }
}

// ---- Router ----

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // The webhook must come before any project auth guard: callbacks carry only
    // uniweb-Signature, never the project API_KEY.
    if (url.pathname === WEBHOOK_PATH) return handleUniwebWebhook(request, env);

    // The project's own auth guard goes after this point, on the business routes.
    if (url.pathname === "/api/pay/link") return createPaymentLink(request, env);
    if (url.pathname === "/api/pay/checkout") return createCheckoutSession(request, env);

    return json({ error: "not found" }, { status: 404 });
  },
};

Common Mistakes

MistakeConsequence / fix
Webhook route blocked by the project's API_KEY / bearer auth guardCallbacks get 401/403 and orders stay pending forever. Exempt WEBHOOK_PATH; trust comes solely from signature verification
Forgetting to set webhookUrl on the link/productEvents fall through to PinMe's managed fallback and are discarded; the Worker never sees them. Business events must point per-link/per-product at this Worker
Passing webhookUrl to checkout.createThe field does not exist. Set it on the backing product
Fulfilling on a successUrl redirectForgeable. Fulfill only from verified webhooks or server-side verification
Hardcoding the callback host or relying only on request.urlBuild from env.WORKER_URL; request.url is a fallback only
Pairing CNY with wechat/alipaySource limits QR methods to SGD only; CNY can only use card
Creating a new product/price on every requestCreate stable catalog items once, persist and reuse the priceId
Calling webhooks.set/remove/rollSecret or wallet.update in ordinary business flowsMutates/rotates the wallet callback and whsec_ shared by ALL of the user's projects. Generate only for explicit wallet-administration requests with admin authorization
Using floating-point major units for amountsAlways integer minor units
Using process.env in the Worker or importing the SDK in the browserUse the env argument; the SDK rejects browser environments
Putting UNIWEB_SECRET / whsec_ in wrangler.toml, source, logs, or D1PinMe injects them at deploy time; locally use an uncommitted .dev.vars only
Reading the body more than once, or as JSON, before verificationRead with request.text() exactly once and pass the raw string to verifyWebhook

Finish Checklist

  • @uniwebpay/sdk is installed only when Worker code imports it; package manager follows the lockfile.
  • Env includes the PinMe-injected bindings the code uses; UNIWEB_WEBHOOK_SECRET / WORKER_URL stay optional and their absence is handled.
  • The client is instantiated with new Uniweb(env.UNIWEB_SECRET, { baseUrl: env.UNIWEB_API_URL, payUrl: env.UNIWEB_PAY_URL }).
  • No VibeCash or PinMe payment proxy routes are used; no secret appears in source, responses, logs, D1, tests, or docs.
  • Amounts are validated integer minor units; payment methods and currencies follow the constraint table (QR methods SGD only).
  • Every payment creation that needs events carries a per-link/per-product webhookUrl built from env.WORKER_URL.
  • Webhook: raw body read exactly once, verifyWebhook verification, correct 400/500 semantics, event.id idempotency, route bypasses project auth, responds within 10 seconds.
  • Fulfillment does not rely on browser redirects; amount, currency, metadata, and order state are verified before granting access.

© glitternetwork, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in skills/pinme-uniwebpay of glitternetwork/pinme.

Open the folder on GitHubat commit 7822b05

Used in 1 other repository

We found 1 copy of this SKILL.md (exact, near-identical or edited) in other folders, from 1 other GitHub owner. This page covers the copy in glitternetwork/pinme, which our catalogue first saw on October 7, 2026.

Compare with similar skills

Pinme Uniwebpay next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Pinme Uniwebpay compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Pinme Uniwebpay this skillglitternetwork/pinme3.7k1 repos~7.3kAutomated safety check: PassMIT
Ar Io Gateway Operatorar-io/ar-io-node127—~8.2kAutomated safety check: NotesAGPL-3.0
Spiceflowremorses/spiceflow167—~1.9kAutomated safety check: PassMIT
Stripe Best Practicesfossasia/eventyay1.7k1 repos~1.7kAutomated safety check: PassApache-2.0
Bfl APIblack-forest-labs/skills1251 repos~2.5kAutomated safety check: NotesMIT
Chat SDK Botslobehub/lobehub83k—~1.5kAutomated safety check: PassCustom licence

Similar skills

  • Ar Io Gateway Operator

    ar-io/ar-io-node

    Operate any AR.IO node deployment — architecture, daily ops, diagnostics, and recurring pitfalls that apply to every operator.

    127 GitHub stars~8.2k tokensUpdated today
    Backend & APIsAuto-check: notes
  • Spiceflow

    remorses/spiceflow

    Spiceflow is a super simple, fast, and type-safe API and React Server Components framework for TypeScript.

    167 GitHub stars~1.9k tokensUpdated 2 days ago
    Backend & APIsAuto-check passed
  • Stripe Best Practices

    fossasia/eventyay

    Guides Stripe integration decisions across development and test environment planning (separate sandboxes vs the shared test mode sandbox), API selection (Checkout Sessions vs PaymentIntents)…

    1.7k GitHub starsUsed in 1 repo~1.7k tokens
    Backend & APIsAuto-check passed
  • Bfl API

    black-forest-labs/skills

    BFL FLUX API integration guide covering endpoints, async polling patterns, rate limiting, error handling, webhooks, and regional endpoints with Python and TypeScript code examples.

    125 GitHub starsUsed in 1 repo~2.5k tokens
    Backend & APIsAuto-check: notes
  • Chat SDK Bots

    lobehub/lobehub

    Builds chat bots once for Slack, Teams, Google Chat, Discord, GitHub and Linear with the Chat SDK, covering event handlers, cards, modals, streaming and state adapters.

    83k GitHub stars~1.5k tokensUpdated today
    Backend & APIsAuto-check passed
  • PayRam Bitcoin Payments

    PayRam/payram-mcp

    Explains accepting on-chain Bitcoin with PayRam's self-hosted setup: HD wallet deposit addresses, mobile app signing for sweeps, and the same payment API as other coins.

    158 GitHub stars~2.1k tokensUpdated yesterday
    Backend & APIsAuto-check passed

More from glitternetwork/pinme

  • Pinme LLM

    glitternetwork/pinme

    A skill your agent uses when a PinMe project (Worker TypeScript) needs to call OpenRouter-backed LLM APIs, including models, chat/completions, streaming, or OpenRouter web search.

    3.7k GitHub starsUsed in 1 repo~2.8k tokens
    Auto-check passed
  • Pinme

    glitternetwork/pinme

    A skill your agent uses when the user mentions "pinme", or needs to upload files, store to IPFS, create/publish/deploy websites or full-stack services (including frontend pages, backend APIs…

    3.7k GitHub stars~3.8k tokensUpdated 26 days ago
    Auto-check: notes
  • Pinme Auth

    glitternetwork/pinme

    A skill your agent uses when a PinMe project (Worker TypeScript) needs to integrate user authentication — creating email/password users, verifying idtokens, querying user info, or listing users via…

    3.7k GitHub stars~2.6k tokensUpdated 26 days ago
    Auto-check passed
  • Pinme Email

    glitternetwork/pinme

    A skill your agent uses when a PinMe project (Worker TypeScript) needs to integrate email sending (sendemail).

    3.7k GitHub stars~1.2k tokensUpdated 26 days ago
    Auto-check passed
  • Pinme R2

    glitternetwork/pinme

    A skill your agent uses when a PinMe Cloudflare Worker needs R2 object storage, including secure file or image upload, streaming download, metadata lookup, deletion, listing, Range requests, or…

    3.7k GitHub stars~3.6k tokensUpdated 26 days ago
    Auto-check passed
  • Pinme Share

    glitternetwork/pinme

    A skill your agent uses when the user wants to share, publish, or upload a static result through PinMe, especially by generating a static HTML share page for a PinMe project link, deployed…

    3.7k GitHub stars~1.2k tokensUpdated 26 days ago
    Auto-check: notes

Categories

Questions about Pinme Uniwebpay

What does Pinme Uniwebpay do?

A skill your agent uses when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout…. Pinme Uniwebpay is an agent skill from glitternetwork/pinme. Use when generating, modifying, or reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links, products/prices, checkout sessions, payment status reads, refunds, subscriptions, or handling UniwebPay webhooks with @uniwebpay/sdk in a PinMe project.

When should I use Pinme Uniwebpay?

Pinme Uniwebpay fits situations like: reviewing PinMe Worker (Cloudflare Worker TypeScript) code that accepts payments through UniwebPay — payment links; products/prices; checkout sessions; payment status reads.

How do I install Pinme Uniwebpay in Claude Code?

Run `npx skills add glitternetwork/pinme --skill pinme-uniwebpay -a claude-code`. Or copy the skill folder (skills/pinme-uniwebpay in glitternetwork/pinme) into .claude/skills/pinme-uniwebpay in your project. Claude Code loads it when a task matches its description.

How do I install Pinme Uniwebpay in Codex?

Run `npx skills add glitternetwork/pinme --skill pinme-uniwebpay -a codex`. Or copy the skill folder (skills/pinme-uniwebpay in glitternetwork/pinme) into .agents/skills/pinme-uniwebpay in your project. Codex loads it when a task matches its description.

Can I use Pinme Uniwebpay in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add glitternetwork/pinme --skill pinme-uniwebpay -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/pinme-uniwebpay, .gemini/skills/pinme-uniwebpay, .github/skills/pinme-uniwebpay and .opencode/skills/pinme-uniwebpay in your project.

What does Pinme Uniwebpay need to run?

Going by SKILL.md and its folder, Pinme Uniwebpay needs credentials named UNIWEB_WEBHOOK_SECRET, UNIWEB_SECRET and API_KEY. Our summary lists: A credential in UNIWEB_SECRET; A credential in UNIWEB_WEBHOOK_SECRET.

Does Pinme Uniwebpay access the network?

SKILL.md names 2 domains. In commands or code: apiskill.uniwebpay.com and skill.uniwebpay.com; the agent is likely to contact these when it follows the instructions. This is read from the text; nothing was executed.

Is Pinme Uniwebpay safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Pinme Uniwebpay use?

Pinme Uniwebpay is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Pinme Uniwebpay use?

About 7.3k tokens (SKILL.md is roughly 29k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Pinme Uniwebpay?

Skills that share tags, products or a category with Pinme Uniwebpay: Ar Io Gateway Operator (ar-io/ar-io-node, 127 stars), Spiceflow (remorses/spiceflow, 167 stars), Stripe Best Practices (fossasia/eventyay, 1.7k stars) and Bfl API (black-forest-labs/skills, 125 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Pinme Uniwebpay?

glitternetwork (a GitHub organization) maintains it in glitternetwork/pinme, which has 3,746 GitHub stars. The repository holds 7 skills in this directory. The repository was last updated on September 12, 2026.

Source: glitternetwork/pinme on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.