Custom payment providers
Report payments from Paddle, Lemon Squeezy or any other provider with the Node.js library, and build the endpoint Voidhash calls to import, cancel, refund and start checkouts.
This page shows how to connect a payment provider Voidhash does not integrate natively, such as Paddle, Lemon Squeezy or bank transfers, from a Node.js backend. The library works in both directions: your server reports each payment, refund and subscription change to Voidhash, and it can host a provider endpoint that Voidhash calls to import products and history, check a subscription, cancel, refund and start checkouts.
Record payments from other sources explains the concepts and the Studio setup: creating the payment source, giving it a slug, and mapping its product keys to your products. Before you start:
- Set up the Node.js library with a secret key that has the
project:purchases:writescope, or full access. - Create the payment source in Studio and note its payment provider id. The source's
Payments API tab shows it in the Transactions endpoint address, after
/payment-providers/. This page reads it fromVOIDHASH_PAYMENT_SOURCE_ID.
Report payments
voidhash.paymentSource() returns a handle for one source. It makes no request, so create it once
and reuse it.
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!);Every call takes your provider's own id first: the transaction, refund or subscription id. Ids are
free-form, and the library escapes them for the URL. Dates are Date objects and amounts are
integers in minor units, so 4900 is 49.00 EUR.
A one-time purchase
A charge without subscriptionId completes a one-time purchase of its product key:
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"),
});Name the buyer with distinctId, the identifier your app passes to identify(). When you don't
know it yet, pass customer: { externalId: "ctm_42" } instead, and both once you do. See
Customers without a distinct id.
A subscription payment
A charge that names a subscription and the period it pays for starts the subscription, renews it, or converts its trial. Report every renewal as its own charge: revenue only comes from charges.
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 charge books revenue but does not decide access on its own. Also report the subscription's state.
A refund
Report a refund under its own id, and name the charge it returns money for. It takes its buyer and
product from the charge, and its currency must be the charge's.
await paddle.refund("re_1", {
refundsTransactionId: "txn_123",
amount: 4900,
currency: "EUR",
occurredAt: new Date("2026-10-22T14:00:00Z"),
});A refund of everything left of the charge ends access; a smaller one is partial and keeps it. See Refunds for the rules.
What comes back
charge and refund resolve with the recorded transaction:
| Field | Notes |
|---|---|
id | Your id for the transaction. |
paymentProviderId | The source it was recorded under. |
type | charge or refund. |
status | applied once it is recorded, pending while it waits. |
pendingReason | product_not_mapped or charge_not_recorded while pending, otherwise null. |
purchaseId | The Voidhash purchase it belongs to, once applied. |
bookedAmount | What it recorded. A refund books less than its amount when less was left of the charge. |
A pending payment is recorded on its own once its product key is mapped or its charge arrives.
You don't send it again.
Retry safely
Reporting is safe to retry. The same id with the same content returns what was recorded and changes nothing, so you can resend after a timeout or let your provider redeliver its events.
The same id with different content rejects with a conflict error whose code is
transaction_conflict. A recorded payment is never edited: correct it with a refund and a new
charge under a new id. getVoidhashApiError reads the error the API answered with:
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;
}Other codes you may see on these calls:
| Code | Type | Meaning |
|---|---|---|
invalid_transaction | invalid_request | The payment breaks a rule, for example a refund in another currency. |
invalid_subscription | invalid_request | The subscription state cannot be recorded as sent. |
payment_source_required | invalid_request | The payment provider id names a store, not a payment source. |
payment_provider_not_found | not_found | The project has no payment provider with that id. |
payment_source_disabled | conflict | The source is disabled in Studio. |
A rejected request records nothing and does not take its id, so you can send it again once it is
fixed. Network failures reject without data; treat them as unknown and retry. See
Errors for every error type.
Sandbox payments
Pass environment: "sandbox" on test payments, refunds and subscription states. They are recorded
apart from production and stay out of production revenue. The default is "production".
Report subscription state
Send a subscription's latest state whenever it changes, under the subscription's id. The state decides access; it carries no money.
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"),
});For a canceled subscription, pass canceledAt with when access ended; it defaults to updatedAt.
Subscriptions explains what each status does to access.
subscription resolves with { id, paymentProviderId, status, pendingReason, subscriptionId }.
status is one of:
applied: the state is recorded.subscriptionIdis the Voidhash subscription.superseded: Voidhash already has a state with a laterupdatedAt, so this one changes nothing. Your provider's events may arrive in any order; pass the provider's own update time asupdatedAt, not the time your server received the event.pending: withpendingReasonproduct_not_mapped, orcharge_not_recordeduntil the first paid period's charge arrives. It is applied on its own.
Translate your provider's webhooks
Your provider tells you about payments through its own webhooks. Translate each event into the calls above, under the provider's ids, so a redelivered event records nothing new. Report a charge before the subscription state it changes.
The ./paddle helpers below stand in for your provider's SDK: verify its signature on the raw body
and map its fields.
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);
});Answer with an error status when a report fails, so your provider redelivers the event. Retrying is safe.
Cancel and refund at the provider
With a provider endpoint that implements them, you can ask Voidhash to cancel a subscription or refund a payment at your provider. Voidhash calls your endpoint, records what it answers, and resolves with the recorded subscription state or refund:
// 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",
});refund and refundTransaction differ in who moves the money. refund reports a refund that
already happened at your provider. refundTransaction asks your provider to make one. When your
provider later sends you its own event for the same cancellation or refund and you report it, nothing
is recorded twice.
When the endpoint cannot act, these reject and nothing is recorded. The message of
getVoidhashApiError(error) says why.
| Code | Type | Meaning |
|---|---|---|
payment_source_endpoint_not_configured | conflict | The source has no endpoint. |
payment_source_endpoint_unsupported | conflict | The endpoint does not implement the operation. |
payment_source_endpoint_rejected | conflict | The endpoint refused, for example with not_found. |
payment_source_endpoint_unavailable | unavailable | The endpoint did not answer in time or answered an error. |
payment_source_endpoint_invalid_response | unavailable | The endpoint answered something Voidhash could not read. |
transaction_not_found | not_found | refundTransaction named a charge that is not recorded. |
Voidhash sends a cancellation or refund to your endpoint only once, because one that timed out may
still have happened. Before you retry an unavailable error, check at your provider whether the
action went through.
Build a provider endpoint
defineProvider from @voidhash/node/providers builds the endpoint. Give it the source's slug and
the endpoint signing secret Studio shows when you connect it, then implement only the operations
your provider supports.
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 }
// checkout: { create }
});Each operation receives its input and a context with requestId, unique per request, and
environment, "production" or "sandbox". Use environment to choose your provider's live or
test account. An operation can be async or return its result directly. Results use Date objects
for dates and the library converts them.
Products
catalog.list feeds the product import in Studio. Return a page of products with the product key
payments name, and a cursor for the next page, or null on the last one:
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.list imports what happened before you connected the source. Voidhash pages through
kind: "subscriptions" first, then kind: "transactions". Return the items of the requested kind
that were created before before, when the import started; anything later arrives through your
reports.
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 };
},
},Items have the shape you report, plus their id. Anything already recorded under that id is not
recorded twice. The library exports the item types:
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,
});Endpoint subscriptions say willRenew where paymentSource().subscription() says
isAutoRenewing. Endpoint items carry no environment: they belong to the request's.
A page holds at most 1,000 items, and record ids are at most 200 characters.
Subscriptions
subscriptions.get returns one subscription's current state, or { subscription: null } when your
provider does not know it. Voidhash calls it when a subscription's period ends without a renewal
charge or a final state.
subscriptions.cancel cancels at your provider, at the end of the period when atPeriodEnd is
true, otherwise at once. Return the subscription's state afterwards; Voidhash records it as if you
had reported it.
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) };
},
},atPeriodEnd is the opposite of the isImmediate option of cancelSubscription().
Refunds
transactions.refund refunds a charge at your provider: amount in minor units, or null for
everything left of it, with an optional reason. Return the refund as a transaction with
type: "refund":
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,
},
};
},
},Checkouts
checkout.create starts a checkout at your provider for a product key and returns where to send
the buyer. Voidhash calls it when your web app asks the web SDK for a checkout for the
signed-in person. Pass the distinctId or customer on to your provider, so the payment you later
report names the same person. Checkouts always arrive as production requests; see
Test checkouts.
checkout: {
async create({ productKey, distinctId, customer, successUrl, cancelUrl }, { environment }) {
if (distinctId === null && customer === null) {
throw new ProviderError("invalid_request", "A checkout needs a buyer.");
}
const session = await paddleClient(environment).createCheckout({
priceId: productKey,
customData: { distinctId },
customerId: customer?.externalId,
successUrl,
cancelUrl,
});
return { url: session.url, expiresAt: session.expiresAt }; // expiresAt is optional
},
},Answer errors deliberately
Throw ProviderError to answer Voidhash with a specific code. Its message is sent as is, so keep it
free of secrets.
throw new ProviderError("not_found", `Paddle has no transaction ${transactionId}`);| Code | Use it when |
|---|---|
not_found | Nothing at your provider matches the input. |
unavailable | Your provider is down or rate limiting you. Voidhash retries. |
invalid_request | The input makes no sense to you, such as a refund for too much. |
Anything else an operation throws is answered as provider_error. Its message is passed on for
sandbox requests only; production requests get a generic message, so log the error yourself.
What the handler does for you
- It verifies the signature of every request with your signing secret, and refuses a request signed more than five minutes away from your server's clock. Keep your clock in sync.
- It refuses requests for another source slug, and checks each operation's input before it calls you.
- It answers
describe, which Test connection in Studio calls, with the operations you implemented. - It answers operations you did not implement with
unsupported_operation. - It checks what your operations return. A result the protocol does not allow, such as a refund
without
type: "refund", is answered asinvalid_resultwith a message that names the field.
Mount the endpoint
The endpoint is a single POST route. defineProvider returns provider.handler, which takes a
web Request and returns a Response. Use it in Next.js route handlers, 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, wrap it with toNodeHandler. The signature covers the exact bytes
Voidhash sent, so mount the route behind express.raw() or before any JSON body parser:
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());When express.json() or a similar parser has already replaced the body with an object, the
handler cannot verify it and answers 400 with a message that names the problem. With no parser
at all, toNodeHandler reads the request stream itself.
For any other framework, pass the raw body and the headers to provider.handle, and send back the
status and JSON body it resolves with:
const { status, body } = await provider.handle({ body: rawBody, headers: request.headers });The providers entrypoint has no Effect variant. It works the same in an Effect app; run your provider calls with your runtime inside each operation.
Test your endpoint
provider.test() signs a request with your secret and runs it through the handler, so you can unit
test your operations without Voidhash. It resolves with the status and the JSON body Voidhash
would receive, so dates in it are ISO 8601 strings.
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" } });
});test sends a sandbox request, so a failing operation's message is visible. Pass
environment: "production" to test with production's. provider.operations lists the operations
you implemented.
Connect it in Studio
On the source's Provider endpoint tab, enter the endpoint's public HTTPS URL and save. Studio
shows the signing secret once: set it as VOIDHASH_PROVIDER_SECRET on your server, then select
Test connection. See Connect an endpoint
for how Voidhash calls it.
Complete example
The Node backend example connects an in-memory payment provider in both directions: a webhook handler that reports payments, refunds and subscription states, and an endpoint that implements every operation.