VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Node.js library
Check access
Receive webhooks
Persons and events
Custom payment providers

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.

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!);

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.

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);
});

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.

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 }
});

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.

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, mount toNodeHandler(provider) before any JSON body parser, which breaks the signature check with a 400.

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());

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.

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" } });
});

Connect it in Studio

Set the signing secret as VOIDHASH_PROVIDER_SECRET and connect the endpoint.

Next steps

  • Node.js backend example
  • Payment sources
  • Receive webhooks
  • Check access

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousPersons and eventsNextPHP library

On this page

Report paymentsA one-time purchaseA subscription paymentA refundRetry safelyReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointProductsHistorySubscriptionsRefundsAnswer errors deliberatelyMount the endpointTest your endpointConnect it in StudioNext steps