VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
Record payments from other sources
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data
Node.js library
Check access from your backend
Receive webhooks
Custom payment providers

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:write scope, 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 from VOIDHASH_PAYMENT_SOURCE_ID.

Report payments

voidhash.paymentSource() returns a handle for one source. It makes no request, so create it once and reuse it.

server/voidhash.ts
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:

FieldNotes
idYour id for the transaction.
paymentProviderIdThe source it was recorded under.
typecharge or refund.
statusapplied once it is recorded, pending while it waits.
pendingReasonproduct_not_mapped or charge_not_recorded while pending, otherwise null.
purchaseIdThe Voidhash purchase it belongs to, once applied.
bookedAmountWhat 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:

CodeTypeMeaning
invalid_transactioninvalid_requestThe payment breaks a rule, for example a refund in another currency.
invalid_subscriptioninvalid_requestThe subscription state cannot be recorded as sent.
payment_source_requiredinvalid_requestThe payment provider id names a store, not a payment source.
payment_provider_not_foundnot_foundThe project has no payment provider with that id.
payment_source_disabledconflictThe 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. subscriptionId is the Voidhash subscription.
  • superseded: Voidhash already has a state with a later updatedAt, so this one changes nothing. Your provider's events may arrive in any order; pass the provider's own update time as updatedAt, not the time your server received the event.
  • pending: with pendingReason product_not_mapped, or charge_not_recorded until 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.

server/paddle-webhooks.ts
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.

CodeTypeMeaning
payment_source_endpoint_not_configuredconflictThe source has no endpoint.
payment_source_endpoint_unsupportedconflictThe endpoint does not implement the operation.
payment_source_endpoint_rejectedconflictThe endpoint refused, for example with not_found.
payment_source_endpoint_unavailableunavailableThe endpoint did not answer in time or answered an error.
payment_source_endpoint_invalid_responseunavailableThe endpoint answered something Voidhash could not read.
transaction_not_foundnot_foundrefundTransaction 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.

server/voidhash-provider.ts
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,
});
willRenew, not isAutoRenewing

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}`);
CodeUse it when
not_foundNothing at your provider matches the input.
unavailableYour provider is down or rate limiting you. Voidhash retries.
invalid_requestThe 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 as invalid_result with 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:

app/voidhash/provider/route.ts
import { provider } from "@/server/voidhash-provider";

export const POST = provider.handler;
server/app.ts
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:

server/app.ts
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());
A JSON body parser breaks the signature

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.

server/voidhash-provider.test.ts
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.

PreviousReceive webhooksNextPHP library

On this page

Report paymentsA one-time purchaseA subscription paymentA refundWhat comes backRetry safelySandbox paymentsReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointProductsHistorySubscriptionsRefundsCheckoutsAnswer errors deliberatelyWhat the handler does for youMount the endpointTest your endpointConnect it in StudioComplete example