Custom payment providers
Report payments from Paddle, Lemon Squeezy or any other provider, and build the endpoint Voidhash calls.
Node.js code for payment sources and their
provider endpoint, which explain the protocol and Studio setup. Use a
client whose secret key has project:purchases:write.
Report payments
paymentSource() makes no request. charge and refund take Date objects and resolve with the
recorded transaction.
import { createVoidhashSdk } from "@voidhash/node";
export const voidhash = createVoidhashSdk({ secretKey: process.env.VOIDHASH_SECRET_KEY! });
export const paddle = voidhash.paymentSource(process.env.VOIDHASH_PAYMENT_SOURCE_ID!);A one-time purchase
Pass customer: { externalId: "ctm_42" } instead of distinctId when you don't know the buyer yet.
const transaction = await paddle.charge("txn_123", {
distinctId: "user_123",
productKey: "LIFETIME",
amount: 4900,
currency: "EUR",
fee: 300, // what the provider kept, if anything
tax: 782, // tax included in `amount`, if any
occurredAt: new Date("2026-10-20T09:30:00Z"),
});A subscription payment
await paddle.charge("txn_124", {
distinctId: "user_123",
productKey: "pri_monthly",
amount: 999,
currency: "EUR",
occurredAt: new Date("2026-10-01T00:00:03Z"),
subscriptionId: "sub_9",
periodStart: new Date("2026-10-01T00:00:00Z"),
periodEnd: new Date("2026-11-01T00:00:00Z"),
});A refund
await paddle.refund("re_1", {
refundsTransactionId: "txn_123",
amount: 4900,
currency: "EUR",
occurredAt: new Date("2026-10-22T14:00:00Z"),
});Retry safely
getVoidhashApiError reads the API error; network failures reject without data, so retry them.
import { getVoidhashApiError } from "@voidhash/node";
try {
await paddle.charge("txn_123", charge);
} catch (error) {
if (getVoidhashApiError(error)?.code === "transaction_conflict") {
// txn_123 was already recorded with other content. Correct it with a refund.
console.warn(`Payment txn_123 differs from the recorded one`);
return;
}
throw error;
}Report subscription state
subscription resolves with { id, paymentProviderId, status, pendingReason, subscriptionId }.
const state = await paddle.subscription("sub_9", {
distinctId: "user_123",
productKey: "pri_monthly",
status: "active", // trialing | active | past_due | canceled | expired
currentPeriodStart: new Date("2026-10-01T00:00:00Z"),
currentPeriodEnd: new Date("2026-11-01T00:00:00Z"),
isAutoRenewing: true,
updatedAt: new Date("2026-10-01T00:00:05Z"),
});Translate your provider's webhooks
The ./paddle helpers stand in for your provider's SDK.
import express from "express";
import { paddle } from "./voidhash";
import { parsePaddleEvent, toCharge, toRefund, toSubscriptionState } from "./paddle";
const app = express();
app.post("/webhooks/paddle", express.raw({ type: "application/json" }), async (req, res) => {
const event = parsePaddleEvent(req.body, req.headers);
switch (event.type) {
case "transaction.completed":
await paddle.charge(event.transaction.id, toCharge(event.transaction));
if (event.subscription) {
await paddle.subscription(event.subscription.id, toSubscriptionState(event.subscription));
}
break;
case "adjustment.refunded":
await paddle.refund(event.refund.id, toRefund(event.refund));
break;
case "subscription.updated":
await paddle.subscription(event.subscription.id, toSubscriptionState(event.subscription));
break;
}
res.sendStatus(200);
});Cancel and refund at the provider
When the endpoint can't act, these reject and getVoidhashApiError(error) gives the code.
// Turns renewal off; access lasts to the end of the paid period.
await paddle.cancelSubscription("sub_9");
// Ends the subscription now.
await paddle.cancelSubscription("sub_9", { isImmediate: true });
// Refunds 15.00 of the charge. Leave `amount` out to refund everything left of it.
const refund = await paddle.refundTransaction("txn_123", {
amount: 1500,
reason: "requested_by_customer",
});Build a provider endpoint
defineProvider takes the source's slug and signing secret; operations get a context with
requestId and environment.
import { defineProvider, ProviderError } from "@voidhash/node/providers";
export const provider = defineProvider({
source: "paddle",
signingSecret: process.env.VOIDHASH_PROVIDER_SECRET!, // vhps_…
// Each of these is optional; the sections below show them.
// catalog: { list }
// history: { list }
// subscriptions: { get, cancel }
// transactions: { refund }
});Products
catalog: {
async list({ cursor }, { environment }) {
const page = await paddleClient(environment).listPrices({ after: cursor });
return {
items: page.prices.map((price) => ({
key: price.id, // the product key, such as pri_monthly
name: price.description,
kind: price.billingCycle ? "subscription" : "one_time", // or "consumable"
})),
nextCursor: page.nextCursor,
};
},
},History
history: {
async list({ kind, cursor, before }, { environment }) {
const client = paddleClient(environment);
if (kind === "subscriptions") {
const page = await client.listSubscriptions({ after: cursor, createdBefore: before });
return { items: page.subscriptions.map(toProviderSubscription), nextCursor: page.nextCursor };
}
const page = await client.listTransactions({ after: cursor, createdBefore: before });
return { items: page.transactions.map(toProviderTransaction), nextCursor: page.nextCursor };
},
},The library exports the item types; endpoint subscriptions say willRenew where reports say
isAutoRenewing.
import type { ProviderSubscription, ProviderTransaction } from "@voidhash/node/providers";
const toProviderSubscription = (subscription: PaddleSubscription): ProviderSubscription => ({
id: subscription.id, // sub_9
distinctId: subscription.customData.distinctId, // user_123, or null
customer: { externalId: subscription.customerId }, // ctm_42
productKey: subscription.priceId, // pri_monthly
status: subscription.status,
currentPeriodStart: subscription.currentPeriod.startsAt,
currentPeriodEnd: subscription.currentPeriod.endsAt,
willRenew: subscription.scheduledChange === null,
canceledAt: subscription.canceledAt,
updatedAt: subscription.updatedAt,
});
const toProviderTransaction = (transaction: PaddleTransaction): ProviderTransaction => ({
id: transaction.id, // txn_123
type: "charge",
customer: { externalId: transaction.customerId },
productKey: transaction.priceId,
amount: transaction.total,
currency: transaction.currencyCode,
occurredAt: transaction.billedAt,
subscriptionId: transaction.subscriptionId,
periodStart: transaction.billingPeriod?.startsAt ?? null,
periodEnd: transaction.billingPeriod?.endsAt ?? null,
});Subscriptions
subscriptions: {
async get({ id }, { environment }) {
const subscription = await paddleClient(environment).findSubscription(id);
return { subscription: subscription ? toProviderSubscription(subscription) : null };
},
async cancel({ id, atPeriodEnd }, { environment }) {
const subscription = await paddleClient(environment).cancelSubscription(id, {
effectiveFrom: atPeriodEnd ? "next_billing_period" : "immediately",
});
return { subscription: toProviderSubscription(subscription) };
},
},Refunds
transactions: {
async refund({ transactionId, amount, reason }, { environment }) {
const refund = await paddleClient(environment).refund(transactionId, { amount, reason });
return {
refund: {
id: refund.id, // re_1
type: "refund",
refundsTransactionId: transactionId,
amount: refund.amount,
currency: refund.currencyCode,
occurredAt: refund.createdAt,
},
};
},
},Answer errors deliberately
Throw ProviderError with not_found, unavailable or invalid_request; anything else becomes
a provider_error.
throw new ProviderError("not_found", `Paddle has no transaction ${transactionId}`);Mount the endpoint
provider.handler takes a web Request and returns a Response, for Next.js, Bun, Deno,
Cloudflare Workers and Hono.
import { provider } from "@/server/voidhash-provider";
export const POST = provider.handler;import { Hono } from "hono";
import { provider } from "./voidhash-provider";
const app = new Hono();
app.post("/voidhash/provider", (c) => provider.handler(c.req.raw));For Express and node:http, mount toNodeHandler(provider) before any JSON body parser, which
breaks the signature check with a 400.
import express from "express";
import { toNodeHandler } from "@voidhash/node/providers";
import { provider } from "./voidhash-provider";
const app = express();
app.post("/voidhash/provider", express.raw({ type: "application/json" }), toNodeHandler(provider));
// Register JSON parsing for your other routes after it.
app.use(express.json());For other frameworks, pass the raw body and headers to provider.handle. It has no Effect variant.
const { status, body } = await provider.handle({ body: rawBody, headers: request.headers });Test your endpoint
provider.test() signs a test request and resolves with the status and JSON body Voidhash
would receive.
import { expect, test } from "vitest";
import { provider } from "./voidhash-provider";
test("refunds part of a charge", async () => {
const { status, body } = await provider.test({
operation: "transaction.refund",
input: { transactionId: "txn_123", amount: 1500, reason: null },
});
expect(status).toBe(200);
expect(body).toMatchObject({ result: { refund: { type: "refund", amount: 1500 } } });
});
test("lists what the endpoint implements", async () => {
const { body } = await provider.test({ operation: "describe" });
expect(body).toMatchObject({ result: { source: "paddle" } });
});Connect it in Studio
Set the signing secret as VOIDHASH_PROVIDER_SECRET and
connect the endpoint.
Next steps
Something wrong or missing on this page? Tell us by email or on Discord.