VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Environments
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

Record payments from other sources

Record purchases and subscriptions you sell outside the stores, such as through another payment provider, bank transfers or vouchers, so they grant access and appear in revenue analytics.

Some customers pay you outside the App Store, Google Play and Stripe: through another payment provider, by bank transfer, or with a voucher. A payment source lets you report those payments to Voidhash. Each one you report becomes a purchase like a store purchase: it grants the product's access, counts in revenue analytics and sends your webhooks.

You report what happens as it happens: each payment and refund, and each change to a subscription. If you also connect an endpoint on your server, Voidhash can import the source's products and history and ask about a subscription it hasn't heard from.

Voidhash cannot check these payments with a store, so it records them as you report them. Their revenue and webhooks are marked verified: false, so you can tell reported revenue from verified revenue.

Add a payment source

In Studio, open Settings → Payment providers, add a Payment source, and give it a name and a slug such as bank-transfer or vouchers. The slug names the source in webhooks and analytics, and cannot be changed later. Add one source for each place you take payments; a project can have as many as you need.

Map your products

A source names what it sells with its own product keys, for example LIFETIME or the voucher code's product. Link each key to the Voidhash product it grants, as you would a store's product: in Products, open the product and add the key under the source. With an endpoint connected, Import products lists the source's products instead, ready to link. A payment for a key you have not linked yet waits, and is recorded as soon as you link it.

Map as many keys to one product as you sell it under, for example a price per currency, region or pricing experiment. Each of them grants the same product.

A mapping applies to All environments by default. When your test system names a product differently, map its test key separately and choose Test only for it, and Production only for the live key.

Create a secret key

Report payments from your backend with a project secret key. A key with full access works; to give a key only what it needs, create it with the project:purchases:write scope.

The key's environment decides where payments are recorded: a production key (vh_sk_…) records production payments, and a test key (vh_sk_test_…) records test payments. Use a test key on staging; see Staging on the same project.

Report each payment

Send each payment once it has settled, under an id that is unique within the source, such as the bank transfer's reference. The source's Payments API tab in Studio shows the address to send it to as its Transactions endpoint: it names your project and the source by their ids.

curl -X PUT "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/payment-providers/$PAYMENT_SOURCE_ID/transactions/tr_10042" \
  -H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "type": "charge",
    "distinctId": "user_123",
    "productKey": "LIFETIME",
    "amount": 4900,
    "currency": "EUR",
    "fee": 0,
    "tax": 818,
    "occurredAt": "2026-10-20T09:30:00Z"
  }'

You can also record a payment by hand with Record payment at the top of the source's page in Studio. The server libraries for Node.js, Go, PHP and Rust report payments, refunds and subscription states for you.

What a payment contains

FieldNotes
typecharge for a payment, refund to give money back.
distinctIdThe identifier your app passes to identify(), or the anonymous id of the web SDK.
customer.externalIdYour source's id for the customer, when you have no distinct id. See below.
productKeyThe source's product key, mapped to a product in Studio. Charges only.
amountWhat the customer paid, tax included, in minor units (cents rather than euros, for example).
currencyISO 4217 code, such as EUR. Revenue analytics converts it to USD at the day's rate.
feeOptional. What the source kept as its commission, so proceeds are what you receive.
taxOptional. The tax included in amount.
occurredAtWhen the customer paid. Revenue is dated by it.
subscriptionIdThe subscription a charge pays for. See Subscriptions.
periodStart, periodEndThe subscription period a charge pays for. Charges with subscriptionId only.
refundsTransactionIdThe id of the charge a refund returns money for. Refunds only.
environmentOptional. production or test, and must match the key's environment. See below.

The key you report with decides a payment's environment. A payment without environment is recorded in the key's environment, and one whose environment disagrees with the key is rejected. Test payments stay out of production revenue and never unlock access in production apps.

The response tells you whether the payment was recorded (status: "applied", with its purchaseId and the bookedAmount it recorded) or waits (status: "pending"), and why, in pendingReason: product_not_mapped until you map its product key, or charge_not_recorded for a refund whose charge you have not reported yet. A pending payment is recorded on its own once what it waits for is there. If it turns out invalid by then, for example a refund in another currency than its charge, it is dropped and you can send it again, corrected, under the same id.

Retries and corrections

Reporting a payment is safe to retry. Sending the same id with the same body again changes nothing and returns the same result. Sending the same id with a different body is rejected with 409 Conflict (transaction_conflict): a recorded payment is never edited. To correct one, refund it and report the right payment under a new id. A request that is rejected, or fails before anything is recorded, does not take its id, so you can send it again.

Refunds

Report a refund under its own id, naming the charge in refundsTransactionId. It takes its customer and product from the charge. You can repeat the charge's distinctId, customer and productKey, but they must match it. Its currency must be the charge's.

  • A refund of everything left of the charge ends the customer's access.
  • A refund of less is partial: it reduces revenue and leaves access in place. Further refunds can return the rest, and the one that returns the last of it ends access.
  • A refund cannot return more than is left of the charge. When refunds of the same charge arrive at the same moment, a later one records only what the others left; its bookedAmount says what it recorded.
  • A charge of 0, such as a free voucher, is ended with a refund of 0.

Customers without a distinct id

When the person who paid has no distinct id yet, name them with customer.externalId, your source's id for them. Voidhash records the purchase under a stand-in person for that customer, and every later payment with the same customer.externalId belongs to the same person.

Once you know who the customer is, report their next payment with both distinctId and customer.externalId. Voidhash merges the stand-in into that person, so the purchases recorded so far grant them access too.

Sell on your website

Your app and your provider run the checkout: your server chooses the price, creates the checkout at the provider and sends the buyer to it. Voidhash learns what was sold when you report the payment. To credit the payment to the visitor who bought, and to the campaign that brought them, carry their distinct id from the browser to the report:

  1. In the browser, read the buyer's distinct id with voidhash.getDistinctId() and send it with your checkout request. It is the anonymous visitor's id, or your user id once you called identify(). For a signed-in user, your server already knows it: it is your own user id.
  2. On your server, create the checkout at your provider and store the distinct id with the order, for example in the checkout's custom data or metadata, or in your own database.
  3. When the provider tells you the order was paid, report the payment with that distinctId. When you do not have it, report the provider's customer id as customer.externalId; see Customers without a distinct id.

With the Node.js library, where paddleApi stands in for your provider's SDK and paddle is the payment source:

Server
// Your checkout route: the browser sends the distinct id with its request.
const checkout = await paddleApi.createTransaction({
  priceId: "pri_monthly",
  customData: { distinctId },
});

// Your provider's webhook, once the order is paid: report it with the same distinct id.
await paddle.charge(transaction.id, {
  distinctId: transaction.customData.distinctId,
  productKey: transaction.priceId,
  amount: transaction.total,
  currency: transaction.currencyCode,
  occurredAt: transaction.billedAt,
});

An anonymous visitor who buys before signing up is fine: the purchase lands on them, and moves to your user with the rest of their history when you later call identify().

In webhooks and analytics

Purchases from a payment source send the usual purchase.completed and purchase.refunded webhooks with provider: "external", source set to the source's slug, and verified: false. Store purchases carry verified: true. Revenue analytics events carry the same labels, so you can break revenue down by source. Test payments carry environment: "test" and reach only test webhooks.

Subscriptions

A subscription has two parts. Its payments are transactions like any other: each charge pays for one period and books revenue. Its state says whether it renews, is past due or has ended, and decides access. Report both, from the events your provider sends.

Report the state

Send the subscription's latest state whenever it changes, under the subscription's id:

curl -X PUT "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/payment-providers/$PAYMENT_SOURCE_ID/subscriptions/sub_9" \
  -H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{
    "distinctId": "user_123",
    "productKey": "pri_monthly",
    "status": "active",
    "currentPeriodStart": "2026-10-01T00:00:00Z",
    "currentPeriodEnd": "2026-11-01T00:00:00Z",
    "isAutoRenewing": true,
    "updatedAt": "2026-10-01T00:00:05Z"
  }'
FieldNotes
distinctId, customer.externalIdThe subscriber, as for a payment.
productKeyThe source's product key, mapped to a subscription product in Studio.
statustrialing, active, past_due, canceled or expired. See below.
currentPeriodStart, currentPeriodEndThe period the subscriber has now.
isAutoRenewingfalse once the subscriber turned renewal off; access lasts to the period's end.
canceledAtOptional. When a canceled subscription ended.
updatedAtWhen your provider last changed the subscription. A state older than the one Voidhash has is ignored, so events may arrive in any order.
environmentOptional. production or test, and must match the key's environment.
  • trialing starts a free trial for the current period.
  • active keeps access. With isAutoRenewing: false the subscription ends at the period's end; turning renewal back on with isAutoRenewing: true renews it again.
  • past_due keeps access while your provider retries the payment.
  • canceled ends access at canceledAt, and expired at the end of the current period.

The response says whether the state was applied, is superseded by a newer one, or is pending, with pendingReason product_not_mapped until you map its product key, or charge_not_recorded until its first paid period's charge arrives. A pending state is applied on its own once that happens.

Report each payment

A subscription's charge names the subscription and the period it pays for. The first one starts the subscription, later ones renew it, and the first paid period after a trial converts the trial:

{
  "type": "charge",
  "distinctId": "user_123",
  "productKey": "pri_monthly",
  "amount": 999,
  "currency": "EUR",
  "occurredAt": "2026-10-01T00:00:03Z",
  "subscriptionId": "sub_9",
  "periodStart": "2026-10-01T00:00:00Z",
  "periodEnd": "2026-11-01T00:00:00Z"
}

A refund of a subscription payment returns its revenue. Access follows the subscription's state, so when the refund also ends the subscription, report the canceled state too.

Connect an endpoint

With an endpoint on your server, Voidhash can call your provider through your code:

  • Import products: the source's products appear in Studio's product import, ready to map.
  • Import history: Voidhash reads the subscriptions, payments and refunds from before you connected the source and records them like reported ones. Anything already recorded under its id is not recorded twice.
  • Check on subscriptions: when a subscription's period ends without a renewal payment or a final state, Voidhash asks your endpoint for its state.
  • Cancel and refund from Voidhash: cancel a subscription or refund a payment from the person's page in Studio, through the API or through your agent. Voidhash asks your endpoint to do it at the provider and records what it answers.

Voidhash's server SDKs implement the endpoint: you write a function for each thing you support, and the SDK checks each request's signature, validates what your functions return, and answers in the format Voidhash expects. Push payments with the same SDK. Each library's guide covers every operation, mounting the endpoint in your framework and testing it: Node.js, Go, PHP and Rust.

voidhash-provider.ts
import { defineProvider } from "@voidhash/node/providers";

export const provider = defineProvider({
  source: "paddle",
  signingSecret: process.env.VOIDHASH_PROVIDER_SECRET!,
  catalog: {
    async list({ cursor }) {
      const page = await paddle.prices.list({ after: cursor });
      return {
        items: page.data.map((price) => ({
          key: price.id,
          name: price.description,
          kind: price.billingCycle ? "subscription" : "one_time",
        })),
        nextCursor: page.nextCursor ?? null,
      };
    },
  },
});

// A route in any framework that speaks web requests, such as Next.js, Hono or Bun
export const POST = provider.handler;
type Paddle struct{ api *paddle.SDK }

func (p *Paddle) ListCatalog(ctx context.Context, req provider.CatalogRequest) (provider.CatalogPage, error) {
	prices, next, err := p.api.ListPrices(ctx, req.Cursor)
	if err != nil {
		return provider.CatalogPage{}, err
	}
	page := provider.CatalogPage{NextCursor: next}
	for _, price := range prices {
		page.Items = append(page.Items, provider.CatalogProduct{
			Key: price.ID, Name: price.Description, Kind: provider.ProductKindSubscription,
		})
	}
	return page, nil
}

handler, err := provider.NewHandler("paddle", os.Getenv("VOIDHASH_PROVIDER_SECRET"), &Paddle{api: api})
mux.Handle("POST /voidhash/provider", handler)
final class PaddleProvider implements CatalogSource
{
    public function __construct(private readonly PaddleApi $paddle)
    {
    }

    public function listCatalog(CatalogRequest $request): CatalogPage
    {
        $page = $this->paddle->prices->list(after: $request->cursor);
        return new CatalogPage(
            items: array_map(
                fn ($price) => new CatalogProduct(key: $price->id, name: $price->description, kind: ProductKind::Subscription),
                $page->data,
            ),
            nextCursor: $page->next,
        );
    }
}

$handler = new ProviderHandler('paddle', getenv('VOIDHASH_PROVIDER_SECRET'), new PaddleProvider($paddle));
$handler->respondToGlobals();
impl CatalogSource for Paddle {
    async fn list_catalog(&self, req: CatalogRequest) -> Result<CatalogPage, ProviderError> {
        let page = self.api.prices(req.cursor.as_deref()).await.map_err(ProviderError::other)?;
        Ok(CatalogPage {
            items: page.data.into_iter()
                .map(|price| CatalogProduct::new(price.id, price.description, ProductKind::Subscription))
                .collect(),
            next_cursor: page.next,
        })
    }
}

let handler = ProviderHandler::new("paddle", secret).catalog(paddle);
let app = Router::new().route("/voidhash/provider", handler.into_axum());

Add the other operations the same way, and only those your provider supports:

OperationReturns
catalog.listPages of the products you sell, with their product keys.
history.listPages of subscriptions, then of payments and refunds, in the shape you report them.
subscription.getOne subscription's current state, or none.
subscription.cancelThe subscription's state after you canceled it at the period's end, or at once.
transaction.refundThe refund you made at the provider: an amount, or everything left of the payment.

Then connect it in Studio:

  1. On the source's Provider endpoint tab, enter the endpoint's URL and select Save changes. Voidhash creates a signing secret and shows it once.
  2. Set the secret on your server, for example as VOIDHASH_PROVIDER_SECRET.
  3. Select Test connection. Voidhash sends a signed request and lists what your endpoint implements.

The endpoint must be reachable over HTTPS at a public address. Voidhash waits up to 10 seconds for an answer. It retries reads that failed on your side, but sends a cancellation or refund only once. Rotate secret replaces the secret; requests signed with the old one stop verifying, so update your server right after.

Add a test endpoint

Every request Voidhash sends your endpoint carries an environment, production or test, like the payments you report. When a staging deployment of your server reports your test payments with a test key, connect it as the source's test endpoint, so Voidhash asks it about those payments and your production deployment only about real ones. Mappings for all environments cover both deployments; see Map your products when their product keys differ.

On the source's Provider endpoint tab, enter the staging deployment's URL under Test endpoint and save. It gets its own signing secret, shown once: set it on the staging server, then select its Test connection, which sends that endpoint a test request. Each endpoint has its own Rotate secret and Disconnect.

RequestGoes to
Test: history import, subscription checks, cancel, refundThe test endpoint, or the endpoint when there is no test endpoint.
Production: history import, subscription checks, cancel, refundThe endpoint only. Voidhash never sends them to the test endpoint.
Product importBoth, each in its own environment. Their products are listed together, by key.

When one of them cannot be read, for example before your production deployment is live, Import products still lists the other's products and says why the rest are missing.

Without a test endpoint, the endpoint receives requests of both environments. A source may also have only a test endpoint while you try it on staging: its test requests work, and production ones fail with payment_source_endpoint_not_configured until you add the endpoint. Import history asks which environment to import: production needs the endpoint, test either one.

Staging on the same project

Your staging deployment can report into the same Voidhash project as production:

  1. Create a test secret key for staging, ideally with only the project:purchases:write scope. Everything it reports is recorded as test, whatever environment says, and a report whose environment disagrees with the key is rejected. Staging cannot record production payments.
  2. Connect the staging deployment as the source's test endpoint, so Voidhash sends it the test requests.
  3. Create staging's checkouts in your provider's sandbox, so they take no real money, and use the test publishable key on your staging website.

Test purchases never unlock access in production apps, even for a distinct id that also names someone in production. Only test keys and test webhooks see them, and Studio shows them under Test. See Environments.

Each deployment verifies requests with its own signing secret, so a request meant for one cannot be replayed against the other. Your handler can also refuse requests for the environment it does not serve.

Cancel and refund from Voidhash

With an endpoint that implements them, these happen at your provider and are recorded in Voidhash like reported changes. When your provider also sends you the same change as an event and you report it, nothing is recorded twice.

  • Cancel a subscription: on the person's page in Studio, choose Cancel on the subscription, or call the API. It cancels at the end of the paid period, or at once with isImmediate: true:

    curl -X POST "https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/payment-providers/$PAYMENT_SOURCE_ID/subscriptions/sub_9/cancel" \
      -H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
      -H "content-type: application/json" \
      -d '{ "isImmediate": false }'
  • Refund a payment: choose Refund on the purchase in Studio, or call POST /api/v1/projects/{projectId}/payment-providers/{paymentProviderId}/transactions/{externalId}/refund with an optional amount in minor units (everything left of the payment without one) and reason.

If your endpoint cannot cancel or refund, Voidhash records nothing and the error's message says why. It is a 409 conflict when no endpoint is connected for the record's environment (a test endpoint never receives production records), the endpoint does not implement the operation or refused it (payment_source_endpoint_not_configured, payment_source_endpoint_unsupported, payment_source_endpoint_rejected), and a 503 unavailable when it did not answer properly (payment_source_endpoint_unavailable, payment_source_endpoint_invalid_response), which you can retry.

PreviousConnect StripeNextNode.js library

On this page

Add a payment sourceMap your productsCreate a secret keyReport each paymentWhat a payment containsRetries and correctionsRefundsCustomers without a distinct idSell on your websiteIn webhooks and analyticsSubscriptionsReport the stateReport each paymentConnect an endpointAdd a test endpointStaging on the same projectCancel and refund from Voidhash