@graphland/pgw-merchant
v3.4.0
Published
Merchant/client SDK for Graphland Payment Gateway — create invoices, check status, and verify webhooks
Readme
@graphland/pgw-merchant
A lightweight, zero-dependency SDK for merchants integrating with the Graphland Payment Gateway.
Contents
- Features
- Installation
- Quick Start
- Webhook Verification
- API Reference
- V2 — Billing & Entitlements
- NestJS
- React
- System payment adapters
- Error Handling
- Retries
- Configuration
- Publishing (maintainers)
- License
Features
- Create invoices — initiate payment requests with idempotency support
- Get invoices — retrieve full invoice details
- Get customer invoices — list billing invoices for a customer
- Check invoice status — quickly see if an invoice is
pending,paid, orfailed - Billing & entitlements — billing customers, subscriptions, add-on purchases, entitlement checks and consumption
- NestJS module — injectable gateway and entitlement guardrails for server-side billing and checkout
- React bindings — bootstrap provider, hooks, and
FeatureGatefor browser feature gating - System payment adapters — framework-free bKash and SSLCommerz adapters for owned credentials
- Verify webhooks — cryptographically verify incoming webhook payloads using HMAC-SHA256
Installation
npm install @graphland/pgw-merchant
# or
yarn add @graphland/pgw-merchant
# or
pnpm add @graphland/pgw-merchant
# or
bun add @graphland/pgw-merchantOptional peer dependencies:
@nestjs/common— for@graphland/pgw-merchant/nestreact— for@graphland/pgw-merchant/react
Package subpaths
| Import | Use for |
| -------------------------------- | --------------------------------------------------------- |
| @graphland/pgw-merchant | Core client, webhooks, types |
| @graphland/pgw-merchant/nest | NestJS module, gateway injection, entitlement enforcement |
| @graphland/pgw-merchant/react | Browser bootstrap provider, hooks, FeatureGate |
| @graphland/pgw-merchant/system | Reusable bKash and SSLCommerz provider protocol adapters |
Quick Start
import {
GraphlandPGWClient,
verifyWebhookSignature,
parseWebhookEvent,
} from "@graphland/pgw-merchant";
import type { WebhookEvent } from "@graphland/pgw-merchant";
// ── Initialise the client ──────────────────────────────────────
const pgw = new GraphlandPGWClient({
apiKey: "graphland_....", // from the Graphland Console
clientId: "my-store", // your client slug (lowercased)
});
// ── 1. Create an invoice ───────────────────────────────────────
const invoice = await pgw.createInvoice({
identifier: "order-123", // your unique payment reference (idempotency)
amount: 1000, // 1000 = 10.00 in your currency
currency: "BDT",
description: "Order #123",
customer: {
name: "John Doe",
email: "[email protected]",
phone: "+8801700000000",
},
successUrl: "https://myshop.com/order/success",
cancelUrl: "https://myshop.com/order/cancel",
failUrl: "https://myshop.com/order/fail",
metadata: {
orderId: "order-123",
},
});
console.log(invoice.paymentUrl); // → hosted checkout URL
// ── 2. Check invoice status ────────────────────────────────────
const status = await pgw.getInvoiceStatus(invoice.invoiceUID);
console.log(status.status); // "pending" | "paid" | "failed"
// ── 3. Get full invoice details ────────────────────────────────
const full = await pgw.getInvoice(invoice.invoiceUID);
console.log(full.invoice.amount, full.client.displayName);
// Pay an already-created invoice. clientId comes from the SDK constructor.
const payment = await pgw.payInvoice(invoice.invoiceUID, {
successUrl: "https://merchant.example.com/payment/success",
cancelUrl: "https://merchant.example.com/payment/cancel",
failUrl: "https://merchant.example.com/payment/failed",
});
console.log(payment.paymentUrl); // redirect the payer to this URLWebhook Verification
Graphland signs every webhook POST with your webhook signing secret (get it from the Console or when you create your client). Always verify the signature before acting on the event.
import {
verifyWebhookSignature,
parseWebhookEvent,
} from "@graphland/pgw-merchant";
import type { WebhookEvent } from "@graphland/pgw-merchant";
// Example using Express / Hono / Node http
app.post("/webhooks/graphland", (req, res) => {
const rawBody = req.rawBody; // must be the exact raw body bytes as a string
const secret = "your-webhook-signing-secret";
// 1. Verify HMAC-SHA256 signature (timestamp + raw body)
if (
!verifyWebhookSignature(
rawBody,
{
signature: req.headers["x-graphland-signature"],
timestamp: req.headers["x-graphland-timestamp"],
},
secret,
)
) {
return res.status(401).send("Invalid signature");
}
// 2. Parse the event payload
const event: WebhookEvent = parseWebhookEvent(JSON.parse(rawBody));
// 3. Handle the event
switch (event.type) {
case "payment.paid":
console.log("Payment received:", event.data);
break;
case "payment.failed":
console.log("Payment failed:", event.data);
break;
default:
console.log("Unhandled event type:", event.type);
}
res.status(200).send("OK");
});API Reference
GraphlandPGWClient
| Method | Description |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| createInvoice(input) | Create or upsert an invoice. Optional items (name, quantity, unitPrice) appear on the invoice document; their total must equal amount. If identifier matches an existing PENDING invoice, the invoice is updated (idempotent). |
| getInvoice(invoiceUID) | Retrieve full invoice details including merchant display info. |
| getCustomerInvoices(billingCustomerId) | List the customer's billing invoices, newest first. |
| getCustomerQuotations(billingCustomerId, { status? }) | List quotations sent to the customer, newest first (drafts excluded). Each has status (sent, accepted, declined, expired), publicUrl for the accept/decline page, and invoiceUID once converted. |
| getInvoiceStatus(invoiceUID) | Get the current status (pending, paid, failed) and redirect URLs. |
| getPublicPreview(invoiceUID, { payNow? }) | Get { url } of the public, read-only invoice page on hosted checkout. Shows Pay now while unpaid; pass payNow: false to hide it. Redirect to it. |
| getInvoiceDocument(invoiceUID) | Render-ready invoice document: seller, billed-to, line items, subtotal, discount, total, amount due. |
| payInvoice(invoiceNumber, input) | Return hosted checkout for an existing invoice, optionally refreshing return URLs; creates no invoice. |
verifyWebhookSignature(rawBody, headers, secret)
Returns true if the HMAC-SHA256 signature matches HMAC(secret, "{timestamp}.{rawBody}").
parseWebhookEvent(body)
Validates and parses an incoming webhook JSON body into a typed WebhookEvent object.
V2 — Billing & Entitlements
Manage billing customers, subscriptions, purchases, and entitlements. All
*Subscription / *Purchase / ensureCustomer calls take an idempotencyKey
that the API deduplicates on.
A runnable end-to-end walkthrough lives in the
sdk-playgroundapp — customer, free & paid subscriptions (paid returns apaymentUrland activates after settlement), plan upgrades and downgrades, consume/check with idempotent replays, and cancellation.
const pgw = new GraphlandPGWClient({ apiKey, clientId: "waland" });
// 1. Create or update a billing customer
await pgw.ensureCustomer(
"org_acme",
{ displayMetadata: { tier: "gold" } },
"req-1",
);
// 2. Start a subscription for a plan + price variant
// Free variants activate instantly; paid variants return
// status "pending_payment" + paymentUrl until the invoice settles.
const sub = await pgw.createSubscription(
{ billingCustomerId: "org_acme", planKey: "pro", priceVariantKey: "30d" },
"req-2",
);
console.log(sub.status); // "active" (free) | "pending_payment" (paid)
const checkout = await pgw.createCheckoutSession(
"org_acme",
{
planKey: "pro",
priceVariantKey: "30d",
successUrl: "https://merchant.example.com/billing/success",
cancelUrl: "https://merchant.example.com/billing/cancel",
failUrl: "https://merchant.example.com/billing/failed",
},
"req-checkout",
);
// 3. Upgrade or downgrade — direction is derived from selected variant prices
const change = await pgw.changePlan(
sub.id,
{ planKey: "enterprise", priceVariantKey: "30d" },
"req-3",
);
// Upgrades charge the prorated difference and apply on settlement;
// downgrades schedule at period end and invoice the new variant then.
if (change.status === "pending_payment") {
console.log(change.invoiceAmount, change.paymentUrl); // charged now
}
// `change.id` is a new subscription that supersedes `sub.id` — use it from here.
// 4. Schedule cancellation at period end
await pgw.cancelSubscription(change.id, "req-4");
// 5. Create an invoice-backed add-on purchase
const purchase = await pgw.createPurchase(
{
billingCustomerId: "org_acme",
addonKey: "token_pack_10k",
successUrl: "https://waland.app/success",
},
"req-5",
);
console.log(purchase.paymentUrl); // hosted checkout for the add-on
// 6. Read / explain an entitlement
const ent = await pgw.getEntitlement("org_acme", "analytics");
const why = await pgw.explainEntitlement("org_acme", "analytics");
// 7. Check a non-consumable quantity (denial is NOT thrown — it's a 200 response)
const check = await pgw.check({
billingCustomerId: "org_acme",
featureKey: "projects",
requestedQuantity: 5,
});
if (!check.allowed) console.log("Denied:", check.code); // "ENTITLEMENT_EXCEEDED"
// 8. Consume a consumable quantity (idempotent under requestId)
const consumed = await pgw.consume({
billingCustomerId: "org_acme",
featureKey: "ai_tokens",
quantity: 500,
requestId: "req_01HX7QK9F2V8B6M3C1L4N5P6Q",
});NestJS
Import @graphland/pgw-merchant/nest to register a global billing module with a private-key PgwGateway and EntitlementService. Full guide: src/nest/README.md.
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { PGWBillingModule } from "@graphland/pgw-merchant/nest";
@Module({
imports: [
ConfigModule.forRoot(),
PGWBillingModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
apiKey: config.getOrThrow("PGW_API_KEY"),
clientId: config.getOrThrow("PGW_CLIENT_ID"),
baseUrl: config.get("PGW_BASE_URL"),
consoleUrl: config.get("APP_URL", "http://localhost:5173"),
displayName: "My Store",
defaultPlanKey: "free",
defaultPriceVariantKey: "30d",
}),
}),
],
})
export class AppModule {}forRoot(config) is available for static configuration. When apiKey or clientId is missing, the gateway logs a warning and throws on use.
Checkout and bootstrap
Inject PAYMENT_GATEWAY (default: PgwGateway) for server-side billing:
import { Controller, Inject, Post, Body } from "@nestjs/common";
import {
PAYMENT_GATEWAY,
type PaymentGateway,
} from "@graphland/pgw-merchant/nest";
@Controller("billing")
export class BillingController {
constructor(
@Inject(PAYMENT_GATEWAY) private readonly gateway: PaymentGateway,
) {}
@Post("checkout")
async checkout(
@Body()
body: {
organizationId: string;
planKey: string;
priceVariantKey: string;
customerEmail?: string;
},
) {
const session = await this.gateway.createCheckoutSession(body);
return { redirectUrl: session.redirectUrl, invoiceId: session.invoiceId };
}
}createCheckoutSession sends one request to PGW. The API owns customer provisioning, trial eligibility, and create-vs-change subscription decisions.
| Method | Description |
| ----------------------------------------------- | -------------------------------------------------- |
| ensureBillingAccount(billingCustomerId) | Create customer + baseline subscription if missing |
| getCatalog() | Published plans, variants, add-ons, features |
| getBootstrap(billingCustomerId) | Catalog + subscription + entitlements |
| getEntitlement(billingCustomerId, featureKey) | Effective entitlement |
| check(input) | Non-consumable limit check |
| consume(input) | Consumable usage under requestId |
| createCheckoutSession(input) | Subscribe or change plan with hosted checkout |
Entitlement enforcement
Inject EntitlementService in request handlers. assertAndConsume throws ForbiddenException when access is denied:
import { Controller, Post, Body } from "@nestjs/common";
import { EntitlementService } from "@graphland/pgw-merchant/nest";
@Controller("ai")
export class AiController {
constructor(private readonly entitlements: EntitlementService) {}
@Post("generate")
async generate(@Body() body: { tenant: string; currentProjects: number }) {
await this.entitlements.assertAndConsume({
tenant: body.tenant,
featureKey: "ai_tokens",
currentQuantity: body.currentProjects,
});
return { ok: true };
}
}Use @graphland/pgw-merchant/react for browser UI gating; use EntitlementService on the server for enforcement.
React
For browser feature gating, create a public API key and use the React subpath.
Full guide: src/react/README.md.
The provider makes one bootstrap request and keeps the result in memory;
FeatureGate and the hooks below read that stored snapshot and do not make
additional requests. The bootstrap response also includes the published
catalog, so browser billing UI can render plan and feature metadata without
calling the private catalog endpoint. When payment is due, it includes
subscription.graceEndsAt and pendingInvoice with the invoice number and
hosted paymentUrl, which can also be refreshed with payInvoice.
import {
FeatureGate,
PgWProvider,
usePgWContext,
useEntitlement,
} from "@graphland/pgw-merchant/react";
function TokenBalance() {
const { entitlement } = useEntitlement("ai_tokens");
return <span>{entitlement?.remaining ?? 0} tokens remaining</span>;
}
function Plans() {
const { data } = usePgWContext();
const catalog = data?.catalog;
return <span>{catalog?.plans.length ?? 0} plans available</span>;
}
export function App() {
return (
<PgWProvider
clientId="my-store"
billingCustomerId="org_acme"
publicApiKey="graphland_..."
baseUrl="https://pgw.graphland.dev"
>
<FeatureGate
featureKey="analytics"
loadingFallback={<span>Loading access…</span>}
fallback={<span>Upgrade to unlock analytics.</span>}
>
<Analytics />
</FeatureGate>
<TokenBalance />
</PgWProvider>
);
}The public key is intentionally visible in browser code and can call the
published catalog and customer bootstrap endpoints. The bootstrap response
contains the published catalog and the computed entitlement snapshot.
FeatureGate is not security enforcement;
protected operations must still be authorized by the merchant server with a
private key.
To show a customer's billing history in the browser, use the invoice hook:
import {
PgWProvider,
useCustomerInvoices,
} from "@graphland/pgw-merchant/react";
function InvoiceHistory() {
const { data: invoices, loading, error } = useCustomerInvoices();
if (loading) return <span>Loading invoices…</span>;
if (error) return <span>Unable to load invoices.</span>;
return <span>{invoices?.length ?? 0} invoices</span>;
}Billing API Reference
| Method | Description |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| ensureCustomer(billingCustomerId, input, idempotencyKey) | Create or update a billing customer (PUT). |
| createSubscription(input, idempotencyKey) | Start a free active subscription. |
| getSubscription(billingCustomerId) | Read the customer's current base subscription. |
| getCatalog() | Read the published plans, prices, add-ons, and feature definitions. Public or private key. |
| getBootstrap(billingCustomerId) | Read the published catalog, current subscription, and all effective entitlements in one request. |
| changePlan(id, input, idempotencyKey) | Upgrade or downgrade; returns the superseding subscription. |
| cancelPlanChange(id, idempotencyKey) | Discard a plan change that has not applied yet. |
| cancelSubscription(id, idempotencyKey) | Schedule cancellation at period end. |
| createPurchase(input, idempotencyKey) | Create an invoice-backed add-on purchase. |
| getEntitlement(billingCustomerId, featureKey) | Read the effective entitlement. |
| explainEntitlement(billingCustomerId, featureKey) | Explain an entitlement decision by grant. |
| check(input) | Check a non-consumable quantity; denial returns allowed: false (HTTP 200). |
| consume(input) | Consume a consumable quantity under a caller requestId. |
System payment adapters
@graphland/pgw-merchant/system exposes the provider protocol layer used by
Graphland itself. It has no NestJS, database, tenant, invoice, or Graphland API
dependency. Applications own credential storage and webhook routes, then pass
the active store's plain configuration into each operation.
import {
BkashPaymentAdapter,
SslcommerzPaymentAdapter,
} from "@graphland/pgw-merchant/system";
const bkash = new BkashPaymentAdapter();
const sslcommerz = new SslcommerzPaymentAdapter();
const intent = await sslcommerz.createPaymentIntent(
{
transactionId: order.id,
amount: order.total,
currency: "BDT",
customer: { name: order.customerName, phone: order.customerPhone },
successUrl: `${storefrontUrl}/orders/${order.id}/success`,
failUrl: `${storefrontUrl}/orders/${order.id}/failed`,
cancelUrl: `${storefrontUrl}/orders/${order.id}/cancelled`,
ipnUrl: `${storefrontApiUrl}/v1/webhooks/sslcommerz`,
},
store.paymentConfig.sslcommerz,
);Adapter instances are reusable across tenants because credentials are never
stored on the instance. bKash access tokens are cached by provider base URL and
app key. Use handleCallback for bKash browser callbacks and handleIpn for
SSLCommerz IPNs; the application remains responsible for locating the order,
checking amount/currency against it, applying settlement idempotently, and
returning the HTTP response.
The bKash adapter implements URL Checkout mode 0011. A successful browser
callback is finalized through /tokenized/checkout/execute; if that single-use
request has an uncertain response, the adapter recovers through
/tokenized/checkout/payment/status. Failure and cancel browser callbacks are
returned as ignored navigation events and never treated as authoritative
settlement. All bKash requests use a 30-second timeout by default (override with
new BkashPaymentAdapter({ timeoutMs })). queryPayment(config, paymentID) is
also available for explicit reconciliation. If you verify browser callback
signatures, persist the opaque signature values from the callback URLs in the
Create Payment raw response and compare them with
verifyBkashCallbackSignature; do not attempt to recreate them with HMAC.
Error Handling
The SDK throws typed errors that include the HTTP status code and the API's
structured error code (VALIDATION_ERROR, IDEMPOTENCY_CONFLICT,
ENTITLEMENT_EXCEEDED, …) when present:
import { ApiError, ConflictApiError, ValidationApiError, EntitlementExceededApiError } from '@graphland/pgw-merchant';
try {
await pgw.createInvoice({ ... });
} catch (err) {
if (err instanceof ValidationApiError) {
console.error('Validation:', err.code, err.message);
} else if (err instanceof ConflictApiError) {
console.error('Conflict:', err.code, err.message);
} else if (err instanceof EntitlementExceededApiError) {
console.error('Entitlement exceeded:', err.code);
} else if (err instanceof ApiError) {
console.error(`API error ${err.statusCode}:`, err.body);
} else {
console.error('Network error:', err);
}
}Retries
Set retries to retry transient failures (network errors, 408/429/5xx) with
exponential backoff. Retries only happen for requests that are safe to re-send:
GETs, and writes carrying an idempotency-key, a consume requestId, or an
invoice identifier — a non-idempotent POST is never retried.
const pgw = new GraphlandPGWClient({
apiKey,
clientId: "waland",
retries: 3,
retryDelayMs: 200,
});Configuration
| Option | Default | Description |
| -------------- | --------------------------- | --------------------------------------------- |
| apiKey | — | Your Graphland integration API key (required) |
| clientId | — | Your lowercased client slug (required) |
| baseUrl | https://pgw.graphland.dev | Override the API base URL for testing |
| timeout | 15_000 | Request timeout in milliseconds |
| retries | 0 | Retry count for idempotent requests |
| retryDelayMs | 200 | Base backoff between retries |
Publishing (maintainers)
From packages/merchant-sdk:
npm run build
npm pack --dry-run # inspect tarball contents
npm login # npmjs.com account in @graphland org
npm publish --access publicScoped packages require --access public on first publish (also set in publishConfig). Bump version in package.json for each release.
License
MIT
